diff --git a/.eslintrc.js b/.eslintrc.js
index 541b3d6153..9d2b7bbb1a 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -31,6 +31,7 @@ module.exports = {
},
},
],
+ 'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 5a182ef106..d24fdfc601 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
-yarn lint-staged
+npx lint-staged
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 78478a26d4..30072ccb1d 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -6,7 +6,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
-import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
@@ -142,79 +141,6 @@ describe('makeRecordUri', () => {
})
})
-// FIXME: Reenable after fixing non-deterministic test.
-describe.skip('ago', () => {
- const oneYearDate = new Date(
- new Date().setMonth(new Date().getMonth() - 11),
- ).setDate(new Date().getDate() - 28)
-
- const inputs = [
- 1671461038,
- '04 Dec 1995 00:12:00 GMT',
- new Date(),
- new Date().setSeconds(new Date().getSeconds() - 10),
- new Date().setMinutes(new Date().getMinutes() - 10),
- new Date().setHours(new Date().getHours() - 1),
- new Date().setDate(new Date().getDate() - 1),
- new Date().setDate(new Date().getDate() - 20),
- new Date().setDate(new Date().getDate() - 25),
- new Date().setDate(new Date().getDate() - 28),
- new Date().setDate(new Date().getDate() - 29),
- new Date().setDate(new Date().getDate() - 30),
- new Date().setMonth(new Date().getMonth() - 1),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 20,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 25,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 28,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 29,
- ),
- new Date().setMonth(new Date().getMonth() - 11),
- new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
- new Date().getDate() - 20,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
- new Date().getDate() - 25,
- ),
- oneYearDate,
- ]
- const outputs = [
- new Date(1671461038).toLocaleDateString(),
- new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
- 'now',
- '10s',
- '10m',
- '1h',
- '1d',
- '20d',
- '25d',
- '28d',
- '29d',
- '1mo',
- '1mo',
- '1mo',
- '1mo',
- '2mo',
- '2mo',
- '11mo',
- '11mo',
- '11mo',
- new Date(oneYearDate).toLocaleDateString(),
- ]
-
- it('correctly calculates how much time passed, in a string', () => {
- for (let i = 0; i < inputs.length; i++) {
- const result = ago(inputs[i])
- expect(result).toEqual(outputs[i])
- }
- })
-})
-
describe('makeValidHandle', () => {
const inputs = [
'test-handle-123',
diff --git a/assets/icons/arrowBottom_stroke2_corner0_rounded.svg b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..5f4a11e09a
--- /dev/null
+++ b/assets/icons/arrowBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg
new file mode 100644
index 0000000000..e3a9d83c80
--- /dev/null
+++ b/assets/icons/newskie.svg
@@ -0,0 +1 @@
+
diff --git a/eslint/index.js b/eslint/index.js
index bb31a942d1..cf5d41225d 100644
--- a/eslint/index.js
+++ b/eslint/index.js
@@ -3,6 +3,7 @@
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
+ 'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
},
}
diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js
new file mode 100644
index 0000000000..06723043fe
--- /dev/null
+++ b/eslint/use-exact-imports.js
@@ -0,0 +1,22 @@
+/* eslint-disable bsky-internal/use-exact-imports */
+const BANNED_IMPORTS = [
+ '@fortawesome/free-regular-svg-icons',
+ '@fortawesome/free-solid-svg-icons',
+]
+
+exports.create = function create(context) {
+ return {
+ Literal(node) {
+ if (typeof node.value !== 'string') {
+ return
+ }
+ if (BANNED_IMPORTS.includes(node.value)) {
+ context.report({
+ node,
+ message:
+ 'Import the specific thing you want instead of the entire package',
+ })
+ }
+ },
+ }
+}
diff --git a/package.json b/package.json
index 5f9a898fc9..3e27a81988 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.86.0",
+ "version": "1.87.0",
"private": true,
"engines": {
"node": ">=18"
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.12.18",
+ "@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -271,6 +271,7 @@
"resolutions": {
"@types/react": "^18",
"**/zeed-dom": "0.10.9",
+ "**/zod": "3.23.8",
"**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1"
diff --git a/patches/expo-haptics+12.8.1.patch b/patches/expo-haptics+13.0.1.patch
similarity index 96%
rename from patches/expo-haptics+12.8.1.patch
rename to patches/expo-haptics+13.0.1.patch
index a95b56f3be..9c7b9a6663 100644
--- a/patches/expo-haptics+12.8.1.patch
+++ b/patches/expo-haptics+13.0.1.patch
@@ -1,9 +1,9 @@
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
-index 26c52af..b949a4c 100644
+index 1520465..6ea988a 100644
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
-
+
private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
diff --git a/patches/expo-updates+0.25.11.patch b/patches/expo-updates+0.25.14.patch
similarity index 96%
rename from patches/expo-updates+0.25.11.patch
rename to patches/expo-updates+0.25.14.patch
index 5f9eceef48..6fc4fc5fcf 100644
--- a/patches/expo-updates+0.25.11.patch
+++ b/patches/expo-updates+0.25.14.patch
@@ -1,11 +1,11 @@
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
-index b85291e..07a5d3c 100644
+index b85291e..546709d 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
status = UpdateStatus.StatusPending
}
-
+
+ // Instead of relying on various hacks to get the correct format for the specific
+ // platform on the backend, we can just add this little patch..
+ let dateFormatter = DateFormatter()
diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch
index 5d2900a731..789ba84ace 100644
--- a/patches/react-native+0.74.1.patch
+++ b/patches/react-native+0.74.1.patch
@@ -1,3 +1,29 @@
+diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+index b0d71dc..9974932 100644
+--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
+ self.backedTextInputView.attributedText = [NSAttributedString new];
+ }
+
+- if (_selectTextOnFocus) {
+- [self.backedTextInputView selectAll:nil];
+- }
+-
+ [_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
+ reactTag:self.reactTag
+ text:[self.backedTextInputView.attributedText.string copy]
+@@ -611,6 +607,10 @@ - (UIView *)reactAccessibilityElement
+ - (void)reactFocus
+ {
+ [self.backedTextInputView reactFocus];
++
++ if (_selectTextOnFocus) {
++ [self.backedTextInputView selectAll:nil];
++ }
+ }
+
+ - (void)reactBlur
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -5,7 +31,7 @@ index e9b330f..1ecdf0a 100644
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
-
+
+- (void)forwarderBeginRefreshing;
+
@end
@@ -16,7 +42,7 @@ index b09e653..4c32b31 100644
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
-
+
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@@ -29,7 +55,7 @@ index b09e653..4c32b31 100644
_onRefresh(nil);
}
}
-
+
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -38,15 +64,15 @@ index b09e653..4c32b31 100644
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
-+
++
+ [self sizeToFit];
-+
++
+ if (!self.scrollView) {
+ return;
+ }
-+
++
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
-+
++
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
@@ -58,7 +84,7 @@ index b09e653..4c32b31 100644
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
-+
++
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
@@ -73,7 +99,7 @@ index 5f5e1ab..aac00b6 100644
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
@@ -99,8 +99,9 @@ public class JavaTimerManager {
}
-
+
// If the JS thread is busy for multiple frames we cancel any other pending runnable.
- if (mCurrentIdleCallbackRunnable != null) {
- mCurrentIdleCallbackRunnable.cancel();
@@ -81,5 +107,5 @@ index 5f5e1ab..aac00b6 100644
+ if (currentRunnable != null) {
+ currentRunnable.cancel();
}
-
+
mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos);
diff --git a/patches/react-native+0.74.1.patch.md b/patches/react-native+0.74.1.patch.md
index 9c93aee5cb..84953df2bd 100644
--- a/patches/react-native+0.74.1.patch.md
+++ b/patches/react-native+0.74.1.patch.md
@@ -11,3 +11,10 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
module.
+
+
+## TextInput Patch - `selectTextOnFocus` fix
+
+Patching `RCTBaseTextInputView.m` to fix an issue where `selectTextOnFocus` does not work as expected on iOS 17. This
+patch _only_ fixes the Paper version of `TextInput`. If we migrate to Fabric and the fix has not been made upstream,
+we can apply the same fix. See https://github.com/facebook/react-native/pull/44307.
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 5b2071e102..18461fdd05 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -14,36 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
+import {
+ initialize,
+ Provider as StatsigProvider,
+ tryFetchGates,
+} from '#/lib/statsig/statsig'
+import {s} from '#/lib/styles'
+import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {s} from 'lib/styles'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import {TestCtrls} from 'view/com/testing/TestCtrls'
-import * as Toast from 'view/com/util/Toast'
-import {Shell} from 'view/shell'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import * as Toast from '#/view/com/util/Toast'
+import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -69,6 +73,9 @@ function InnerApp() {
try {
if (account) {
await resumeSession(account)
+ } else {
+ await initialize()
+ await tryFetchGates(undefined, 'prefer-fresh-gates')
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -105,10 +112,12 @@ function InnerApp() {
-
-
-
-
+
+
+
+
+
+
@@ -147,21 +156,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 5c4dc4e637..6af3c7d6fb 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
+import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import * as Toast from 'view/com/util/Toast'
-import {ToastContainer} from 'view/com/util/Toast.web'
-import {Shell} from 'view/shell/index'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import * as Toast from '#/view/com/util/Toast'
+import {ToastContainer} from '#/view/com/util/Toast.web'
+import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +96,11 @@ function InnerApp() {
-
-
-
+
+
+
+
+
@@ -136,21 +138,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 1ccb0460c4..1dc2dfa7b0 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -267,6 +267,9 @@ export const atoms = {
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
+ font_heavy: {
+ fontWeight: tokens.fontWeight.heavy,
+ },
italic: {
fontStyle: 'italic',
},
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 1bddd95d43..675844e296 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -118,6 +118,7 @@ export const fontWeight = {
normal: '400',
semibold: '500',
bold: '600',
+ heavy: '700',
} as const
export const gradients = {
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
new file mode 100644
index 0000000000..94d97cb620
--- /dev/null
+++ b/src/components/FeedCard.tsx
@@ -0,0 +1,205 @@
+import React from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {msg, plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {
+ useAddSavedFeedsMutation,
+ usePreferencesQuery,
+ useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import * as Toast from 'view/com/util/Toast'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {useRichText} from '#/components/hooks/useRichText'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Link as InternalLink} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+
+export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export function Link({
+ children,
+ feed,
+}: {
+ children: React.ReactElement
+ feed: AppBskyFeedDefs.GeneratorView
+}) {
+ const href = React.useMemo(() => {
+ const urip = new AtUri(feed.uri)
+ const handleOrDid = feed.creator.handle || feed.creator.did
+ return `/profile/${handleOrDid}/feed/${urip.rkey}`
+ }, [feed])
+ return {children}
+}
+
+export function Outer({children}: {children: React.ReactNode}) {
+ return {children}
+}
+
+export function Header({children}: {children: React.ReactNode}) {
+ return {children}
+}
+
+export function Avatar({src}: {src: string | undefined}) {
+ return
+}
+
+export function TitleAndByline({
+ title,
+ creator,
+}: {
+ title: string
+ creator: AppBskyActorDefs.ProfileViewBasic
+}) {
+ const t = useTheme()
+
+ return (
+
+
+ {title}
+
+
+ Feed by {sanitizeHandle(creator.handle, '@')}
+
+
+ )
+}
+
+export function Description({description}: {description?: string}) {
+ const [rt, isResolving] = useRichText(description || '')
+ if (!description) return null
+ return isResolving ? (
+
+ ) : (
+
+ )
+}
+
+export function Likes({count}: {count: number}) {
+ const t = useTheme()
+ return (
+
+ {plural(count || 0, {
+ one: 'Liked by # user',
+ other: 'Liked by # users',
+ })}
+
+ )
+}
+
+export function Action({uri, pin}: {uri: string; pin?: boolean}) {
+ const {hasSession} = useSession()
+ if (!hasSession) return null
+ return
+}
+
+function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
+ const {_} = useLingui()
+ const {data: preferences} = usePreferencesQuery()
+ const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
+ useAddSavedFeedsMutation()
+ const {isPending: isRemovePending, mutateAsync: removeFeed} =
+ useRemoveFeedMutation()
+ const savedFeedConfig = React.useMemo(() => {
+ return preferences?.savedFeeds?.find(
+ feed => feed.type === 'feed' && feed.value === uri,
+ )
+ }, [preferences?.savedFeeds, uri])
+ const removePromptControl = Prompt.usePromptControl()
+ const isPending = isAddSavedFeedPending || isRemovePending
+
+ const toggleSave = React.useCallback(
+ async (e: GestureResponderEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ try {
+ if (savedFeedConfig) {
+ await removeFeed(savedFeedConfig)
+ } else {
+ await saveFeeds([
+ {
+ type: 'feed',
+ value: uri,
+ pinned: pin || false,
+ },
+ ])
+ }
+ Toast.show(_(msg`Feeds updated!`))
+ } catch (e: any) {
+ logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
+ Toast.show(_(msg`Failed to update feeds`))
+ }
+ },
+ [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
+ )
+
+ const onPrompRemoveFeed = React.useCallback(
+ async (e: GestureResponderEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ removePromptControl.open()
+ },
+ [removePromptControl],
+ )
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index a8bdb763d7..63f61ce856 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -100,7 +100,7 @@ function KnownFollowersInner({
moderation,
}
})
- const count = cachedKnownFollowers.count - Math.min(slice.length, 2)
+ const count = cachedKnownFollowers.count
return (
{
+ const name = profile.displayName || profile.handle
+ if (!moderationOpts) return name
+ const moderation = moderateProfile(profile, moderationOpts)
+ return sanitizeDisplayName(name, moderation.ui('displayName'))
+ }, [moderationOpts, profile])
+ const [now] = React.useState(() => Date.now())
+ const timeAgo = useGetTimeAgo()
+ const createdAt = profile.createdAt as string | undefined
+ const daysOld = React.useMemo(() => {
+ if (!createdAt) return Infinity
+ return differenceInSeconds(now, new Date(createdAt)) / 86400
+ }, [createdAt, now])
+
+ if (!createdAt || daysOld > 7) return null
+
+ return (
+
+
+
+
+
+
+
+
+ Say hello!
+
+
+
+ {profileName} joined Bluesky{' '}
+ {timeAgo(createdAt, now, {format: 'long'})} ago
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index e17977af43..319eccfa4a 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -51,10 +51,23 @@ const floatingMiddlewares = [
]
export function ProfileHoverCard(props: ProfileHoverCardProps) {
+ const prefetchProfileQuery = usePrefetchProfileQuery()
+ const prefetchedProfile = React.useRef(false)
+ const onPointerMove = () => {
+ if (!prefetchedProfile.current) {
+ prefetchedProfile.current = true
+ prefetchProfileQuery(props.did)
+ }
+ }
+
if (props.disable || isTouchDevice) {
return props.children
} else {
- return
+ return (
+
+
+
+ )
}
}
@@ -456,7 +469,7 @@ function Inner({
)}
-
+
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index d05cab5ab6..315ad0dfda 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -1,10 +1,10 @@
import React from 'react'
-import {View} from 'react-native'
+import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {Button, ButtonColor, ButtonText} from '#/components/Button'
+import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -136,7 +136,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
- onPress: () => void
+ onPress: ButtonProps['onPress']
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -147,9 +147,12 @@ export function Action({
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
- const handleOnPress = React.useCallback(() => {
- close(onPress)
- }, [close, onPress])
+ const handleOnPress = React.useCallback(
+ (e: GestureResponderEvent) => {
+ close(() => onPress?.(e))
+ },
+ [close, onPress],
+ )
return (