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 ( + + )} + + ) } function Rule({ @@ -130,15 +174,15 @@ function Rule({ post, lists, }: { - rule: any + rule: ThreadgateSetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { const pal = usePalette('default') - if (AppBskyFeedThreadgate.isMentionRule(rule)) { + if (rule.type === 'mention') { return mentioned users } - if (AppBskyFeedThreadgate.isFollowingRule(rule)) { + if (rule.type === 'following') { return ( users followed by{' '} @@ -151,7 +195,7 @@ function Rule({ ) } - if (AppBskyFeedThreadgate.isListRule(rule)) { + if (rule.type === 'list') { const list = lists?.find(l => l.uri === rule.list) if (list) { const listUrip = new AtUri(list.uri) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index df45174b9a..3f855c336c 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -3,15 +3,14 @@ import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' -import {precacheProfile, usePrefetchProfileQuery} from '#/state/queries/profile' +import {precacheProfile} from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {niceDate} from 'lib/strings/time' import {TypographyVariant} from 'lib/ThemeContext' -import {isAndroid, isWeb} from 'platform/detection' -import {atoms as a} from '#/alf' +import {isAndroid} from 'platform/detection' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {TextLinkOnWebOnly} from './Link' import {Text} from './text/Text' @@ -37,17 +36,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const pal = usePalette('default') const displayName = opts.author.displayName || opts.author.handle const handle = opts.author.handle - const prefetchProfileQuery = usePrefetchProfileQuery() - const profileLink = makeProfileLink(opts.author) - const prefetchedProfile = React.useRef(false) - const onPointerMove = React.useCallback(() => { - if (!prefetchedProfile.current) { - prefetchedProfile.current = true - prefetchProfileQuery(opts.author.did) - } - }, [opts.author.did, prefetchProfileQuery]) - const queryClient = useQueryClient() const onOpenAuthor = opts.onOpenAuthor const onBeforePressAuthor = useCallback(() => { @@ -71,39 +60,35 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { )} - - - - {sanitizeDisplayName( - displayName, - opts.moderation?.ui('displayName'), - )} - - } - href={profileLink} - onBeforePress={onBeforePressAuthor} - /> - - - + + + {sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + )} + + } + href={profileLink} + onBeforePress={onBeforePressAuthor} + /> + + {!isAndroid && ( JSX.Element timeToString?: (timeElapsed: string) => string }) { + const ago = useGetTimeAgo() + const format = timeToString ?? ago const tick = useTickEveryMinute() const [timeElapsed, setTimeAgo] = React.useState(() => - timeToString(timestamp), + format(timestamp, tick), ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(timeToString(timestamp)) + setTimeAgo(format(timestamp, tick)) } return children({timeElapsed}) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 2486b73d58..45e00e58c7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -7,7 +7,7 @@ import { } from 'react-native' import * as Clipboard from 'expo-clipboard' import { - AppBskyActorDefs, + AppBskyFeedDefs, AppBskyFeedPost, AtUri, RichText as RichTextAPI, @@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {usePostDeleteMutation} from '#/state/queries/post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' import {useSession} from '#/state/session' import {getCurrentRoute} from 'lib/routes/helpers' import {shareUrl} from 'lib/sharing' @@ -62,9 +65,7 @@ import * as Toast from '../Toast' let PostDropdownBtn = ({ testID, - postAuthor, - postCid, - postUri, + post, postFeedContext, record, richText, @@ -74,9 +75,7 @@ let PostDropdownBtn = ({ timestamp, }: { testID: string - postAuthor: AppBskyActorDefs.ProfileViewBasic - postCid: string - postUri: string + post: Shadow postFeedContext: string | undefined record: AppBskyFeedPost.Record richText: RichTextAPI @@ -92,8 +91,6 @@ let PostDropdownBtn = ({ const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const langPrefs = useLanguagePrefs() - const mutedThreads = useMutedThreads() - const toggleThreadMute = useToggleThreadMute() const postDeleteMutation = usePostDeleteMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() @@ -107,9 +104,15 @@ let PostDropdownBtn = ({ const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() + const postUri = post.uri + const postCid = post.cid + const postAuthor = post.author const rootUri = record.reply?.root?.uri || postUri - const isThreadMuted = mutedThreads.includes(rootUri) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did @@ -162,18 +165,22 @@ let PostDropdownBtn = ({ const onToggleThreadMute = React.useCallback(() => { try { - const muted = toggleThreadMute(rootUri) - if (muted) { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() Toast.show( _(msg`You will no longer receive notifications for this thread`), ) - } else { - Toast.show(_(msg`You will now receive notifications for this thread`)) } - } catch (e) { - logger.error('Failed to toggle thread mute', {message: e}) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show(_(msg`Failed to toggle thread mute, please try again`)) + } } - }, [rootUri, toggleThreadMute, _]) + }, [isThreadMuted, unmuteThread, _, muteThread]) const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) diff --git a/src/view/com/util/layouts/LoggedOutLayout.tsx b/src/view/com/util/layouts/LoggedOutLayout.tsx index 0272a44c6b..c2c080c171 100644 --- a/src/view/com/util/layouts/LoggedOutLayout.tsx +++ b/src/view/com/util/layouts/LoggedOutLayout.tsx @@ -3,6 +3,7 @@ import {ScrollView, StyleSheet, View} from 'react-native' import {isWeb} from '#/platform/detection' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {atoms as a} from '#/alf' @@ -29,13 +30,18 @@ export const LoggedOutLayout = ({ borderLeftWidth: 1, }) + const [isKeyboardVisible] = useIsKeyboardVisible() + if (isMobile) { if (scrollable) { return ( + keyboardDismissMode="none" + contentContainerStyle={[ + {paddingBottom: isKeyboardVisible ? 300 : 0}, + ]}> {children} ) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index c389855e3d..c0e743db48 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -319,9 +319,7 @@ let PostCtrls = ({ {text} control.close()} cta={_(msg`Close`)} color="secondary" /> diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index 025b903b22..b4feed990c 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -1,5 +1,5 @@ import {library} from '@fortawesome/fontawesome-svg-core' -import {faAddressCard} from '@fortawesome/free-regular-svg-icons' +import {faAddressCard} from '@fortawesome/free-regular-svg-icons/faAddressCard' import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark' import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' @@ -25,8 +25,6 @@ import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck' import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' -import {faFlask} from '@fortawesome/free-solid-svg-icons' -import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons' import {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown' import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft' import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight' @@ -62,6 +60,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' +import {faFlask} from '@fortawesome/free-solid-svg-icons/faFlask' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' @@ -97,6 +96,7 @@ import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal' import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' +import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons/faUniversalAccess' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 76ff4268fd..1345211775 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,6 +1,6 @@ import React from 'react' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {cleanError} from 'lib/strings/errors' import {s} from 'lib/styles' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {FAB} from 'view/com/util/fab/FAB' import {SearchInput} from 'view/com/util/forms/SearchInput' @@ -46,6 +45,8 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import hairlineWidth = StyleSheet.hairlineWidth +import {Divider} from '#/components/Divider' +import * as FeedCard from '#/components/FeedCard' type Props = NativeStackScreenProps @@ -94,6 +95,7 @@ type FlatlistSlice = type: 'popularFeed' key: string feedUri: string + feed: AppBskyFeedDefs.GeneratorView } | { type: 'popularFeedsLoadingMore' @@ -104,22 +106,6 @@ type FlatlistSlice = key: string } -// HACK -// the protocol doesn't yet tell us which feeds are personalized -// this list is used to filter out feed recommendations from logged out users -// for the ones we know need it -// -prf -const KNOWN_AUTHED_ONLY_FEEDS = [ - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed - 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz - 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why -] - export function FeedsScreen(_props: Props) { const pal = usePalette('default') const {openComposer} = useComposerControls() @@ -316,6 +302,7 @@ export function FeedsScreen(_props: Props) { key: `popularFeed:${feed.uri}`, type: 'popularFeed', feedUri: feed.uri, + feed, })), ) } @@ -327,10 +314,7 @@ export function FeedsScreen(_props: Props) { type: 'popularFeedsLoading', }) } else { - if ( - !popularFeeds?.pages || - popularFeeds?.pages[0]?.feeds?.length === 0 - ) { + if (!popularFeeds?.pages) { slices.push({ key: 'popularFeedsNoResults', type: 'popularFeedsNoResults', @@ -338,26 +322,12 @@ export function FeedsScreen(_props: Props) { } else { for (const page of popularFeeds.pages || []) { slices = slices.concat( - page.feeds - .filter(feed => { - if ( - !hasSession && - KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) - ) { - return false - } - const alreadySaved = Boolean( - preferences?.savedFeeds?.find(f => { - return f.value === feed.uri - }), - ) - return !alreadySaved - }) - .map(feed => ({ - key: `popularFeed:${feed.uri}`, - type: 'popularFeed', - feedUri: feed.uri, - })), + page.feeds.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + feed, + })), ) } @@ -495,7 +465,7 @@ export function FeedsScreen(_props: Props) { return ( <> - + } else if (item.type === 'popularFeed') { return ( - + + + + ) } else if (item.type === 'popularFeedsNoResults') { return ( @@ -559,7 +526,6 @@ export function FeedsScreen(_props: Props) { onPressCancelSearch, onSubmitQuery, onChangeSearchFocus, - hasSession, ], ) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index e727a1fb81..e6040b77e2 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,18 +1,20 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ScrollView} from '../com/util/Views' -import {s} from 'lib/styles' -import {ViewHeader} from '../com/util/ViewHeader' -import {Text} from '../com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {getEntries} from '#/logger/logDump' -import {ago} from 'lib/strings/time' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {getEntries} from '#/logger/logDump' +import {useTickEveryMinute} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell' +import {usePalette} from 'lib/hooks/usePalette' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {s} from 'lib/styles' +import {Text} from '../com/util/text/Text' +import {ViewHeader} from '../com/util/ViewHeader' +import {ScrollView} from '../com/util/Views' export function LogScreen({}: NativeStackScreenProps< CommonNavigatorParams, @@ -22,6 +24,8 @@ export function LogScreen({}: NativeStackScreenProps< const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState([]) + const timeAgo = useGetTimeAgo() + const tick = useTickEveryMinute() useFocusEffect( React.useCallback(() => { @@ -70,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps< /> ) : undefined} - {ago(entry.timestamp)} + {timeAgo(entry.timestamp, tick)} {expanded.includes(entry.id) ? ( diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx new file mode 100644 index 0000000000..dd93bf8130 --- /dev/null +++ b/src/view/screens/Search/Explore.tsx @@ -0,0 +1,560 @@ +import React from 'react' +import {View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + moderateProfile, + ModerationDecision, + ModerationOpts, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {cleanError} from 'lib/strings/errors' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import { + FeedFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Button} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +function SuggestedItemsHeader({ + title, + description, + style, + icon: Icon, +}: { + title: string + description: string + icon: React.ComponentType +} & ViewStyleProp) { + const t = useTheme() + + return ( + + + + + {title} + + + {description} + + + + ) +} + +type LoadMoreItems = + | { + type: 'profile' + key: string + avatar: string + moderation: ModerationDecision + } + | { + type: 'feed' + key: string + avatar: string + moderation: undefined + } + +function LoadMore({ + item, + moderationOpts, +}: { + item: ExploreScreenItems & {type: 'loadMore'} + moderationOpts?: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + const items = React.useMemo(() => { + return item.items + .map(_item => { + if (_item.type === 'profile') { + return { + type: 'profile', + key: _item.profile.did, + avatar: _item.profile.avatar, + moderation: moderateProfile(_item.profile, moderationOpts!), + } + } else if (_item.type === 'feed') { + return { + type: 'feed', + key: _item.feed.uri, + avatar: _item.feed.avatar, + moderation: undefined, + } + } + return undefined + }) + .filter(Boolean) as LoadMoreItems[] + }, [item.items, moderationOpts]) + + if (items.length === 0) return null + + const type = items[0].type + + return ( + + + + ) +} + +type ExploreScreenItems = + | { + type: 'header' + key: string + title: string + description: string + style?: ViewStyleProp['style'] + icon: React.ComponentType + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileViewBasic + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + isLoadingMore: boolean + onLoadMore: () => void + items: ExploreScreenItems[] + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + +export function Explore() { + const {_} = useLingui() + const t = useTheme() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const moderationOpts = useModerationOpts() + const { + data: profiles, + hasNextPage: hasNextProfilesPage, + isLoading: isLoadingProfiles, + isFetchingNextPage: isFetchingNextProfilesPage, + error: profilesError, + fetchNextPage: fetchNextProfilesPage, + } = useSuggestedFollowsQuery({limit: 3}) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 3}) + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = React.useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return + try { + await fetchNextProfilesPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) + + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const onLoadMoreFeeds = React.useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + ]) + + const items = React.useMemo(() => { + const i: ExploreScreenItems[] = [ + { + type: 'header', + key: 'suggested-follows-header', + title: _(msg`Suggested accounts`), + description: _( + msg`Follow more accounts to get connected to your interests and build your network.`, + ), + icon: Person, + }, + ] + + if (profiles) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did)) { + seen.add(actor.did) + i.push({ + type: 'profile', + key: actor.did, + profile: actor, + }) + } + } + } + + if (hasNextProfilesPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + items: i.filter(item => item.type === 'profile').slice(-3), + }) + } + } else { + if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } + } + + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Discover new feeds`), + description: _( + msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`, + ), + style: [a.pt_5xl], + icon: ListSparkle, + }) + + if (feeds && preferences) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + for (const page of feeds.pages) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + i.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else if (hasNextFeedsPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + items: i.filter(item => item.type === 'feed').slice(-3), + }) + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + + return i + }, [ + _, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + hasNextProfilesPage, + hasNextFeedsPage, + ]) + + const renderItem = React.useCallback( + ({item}: {item: ExploreScreenItems}) => { + switch (item.type) { + case 'header': { + return ( + + ) + } + case 'profile': { + return ( + + + + ) + } + case 'feed': { + return ( + + + + ) + } + case 'loadMore': { + return + } + case 'profilePlaceholder': { + return + } + case 'feedPlaceholder': { + return + } + case 'error': { + return ( + + + + + + {item.message} + + + {item.error} + + + + + ) + } + } + }, + [t, moderationOpts], + ) + + return ( + item.key} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 200}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + /> + ) +} diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b6daf84b38..0b1fe37aaf 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -29,15 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons' import {makeProfileLink} from '#/lib/routes/links' import {NavigationProp} from '#/lib/routes/types' import {augmentSearchQuery} from '#/lib/strings/helpers' -import {s} from '#/lib/styles' import {logger} from '#/logger' -import {isIOS, isNative, isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' +import {usePopularFeedsSearch} from '#/state/queries/feed' import {useSearchPostsQuery} from '#/state/queries/search-posts' -import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' @@ -56,9 +55,10 @@ import {Link} from '#/view/com/util/Link' import {List} from '#/view/com/util/List' import {Text} from '#/view/com/util/text/Text' import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {Explore} from '#/view/screens/Search/Explore' import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme as useThemeNew} from '#/alf' +import * as FeedCard from '#/components/FeedCard' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' function Loader() { @@ -122,70 +122,6 @@ function EmptyState({message, error}: {message: string; error?: string}) { ) } -function useSuggestedFollows(): [ - AppBskyActorDefs.ProfileViewBasic[], - () => void, -] { - const { - data: suggestions, - hasNextPage, - isFetchingNextPage, - isError, - fetchNextPage, - } = useSuggestedFollowsQuery() - - const onEndReached = React.useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more suggested follows', {message: err}) - } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - - const items: AppBskyActorDefs.ProfileViewBasic[] = [] - if (suggestions) { - // Currently the responses contain duplicate items. - // Needs to be fixed on backend, but let's dedupe to be safe. - let seen = new Set() - for (const page of suggestions.pages) { - for (const actor of page.actors) { - if (!seen.has(actor.did)) { - seen.add(actor.did) - items.push(actor) - } - } - } - } - return [items, onEndReached] -} - -let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { - const pal = usePalette('default') - const [suggestions, onEndReached] = useSuggestedFollows() - - return suggestions.length ? ( - } - keyExtractor={item => item.did} - // @ts-ignore web only -prf - desktopFixedHeight - contentContainerStyle={{paddingBottom: 200}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - onEndReached={onEndReached} - onEndReachedThreshold={2} - /> - ) : ( - - - - - ) -} -SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) - type SearchResultSlice = | { type: 'post' @@ -342,6 +278,52 @@ let SearchScreenUserResults = ({ } SearchScreenUserResults = React.memo(SearchScreenUserResults) +let SearchScreenFeedsResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const t = useThemeNew() + const {_} = useLingui() + + const {data: results, isFetched} = usePopularFeedsSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + ( + + + + )} + keyExtractor={item => item.uri} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} +SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) + let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() @@ -389,6 +371,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ), }, + { + title: _(msg`Feeds`), + component: ( + + ), + }, ] }, [_, query, activeTab]) @@ -408,26 +396,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ))} ) : hasSession ? ( - - - - Suggested Follows - - - - - + ) : ( } if (hasSession && currentAccount?.signupQueued) { diff --git a/yarn.lock b/yarn.lock index c56a56b28e..700ddfe072 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.18": - version "0.12.18" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" - integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== +"@atproto/api@^0.12.20": + version "0.12.20" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3" + integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" @@ -22470,12 +22470,7 @@ zod-validation-error@^3.0.3: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== -zod@^3.14.2, zod@^3.20.2: - version "3.22.2" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" - integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== - -zod@^3.21.4, zod@^3.22.4: +zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4: version "3.23.8" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==