Merge remote-tracking branch 'origin/main' into verify-email-reminders

* origin/main: (35 commits)
  Bump labeler limit to 20 (#4565)
  Migrate local thread mutes (#4523)
  Disable newskie dialog tap in hover card web (#4562)
  Implement thread locking (#4545)
  Prevent unecessary calls (#4561)
  Force callers of `getTimeAgo` to pass in the value for "now" (#4560)
  Fix: only apply self-thread load-more behavior on the outer edge of the reply tree (#4559)
  Server-side thread mutes (#4518)
  Explore fixes (#4540)
  Is it "newskie" or "newsky" 🤔  (#4557)
  fix keyboard overlaying onboarding inputs (#4558)
  Add `useGetTimeAgo` and utils (#4556)
  Unconditionally polyfill Intl.PluralRules for native (#4554)
  Dedupe Zod installation (#4551)
  Use exact imports for icons (#4549)
  Fix Android startup perf regression (#4544)
  Explore feed cards (#4521)
  Onboarding fixes (#4508)
  Add `native_pwi_disabled` feature gate experiment (#4507)
  Select, don't mutate (#4541)
  ...
This commit is contained in:
Eric Bailey
2024-06-18 16:26:35 -05:00
69 changed files with 2040 additions and 722 deletions
+1
View File
@@ -31,6 +31,7 @@ module.exports = {
}, },
}, },
], ],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error', 'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [ 'simple-import-sort/imports': [
'warn', 'warn',
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged npx lint-staged
-74
View File
@@ -6,7 +6,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers' import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection' import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip' import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
import { import {
makeRecordUri, makeRecordUri,
toNiceDomain, 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', () => { describe('makeValidHandle', () => {
const inputs = [ const inputs = [
'test-handle-123', 'test-handle-123',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 285 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 848 B

+1
View File
@@ -3,6 +3,7 @@
module.exports = { module.exports = {
rules: { rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'), 'use-typed-gates': require('./use-typed-gates'),
}, },
} }
+22
View File
@@ -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',
})
}
},
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.86.0", "version": "1.87.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.18", "@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -271,6 +271,7 @@
"resolutions": { "resolutions": {
"@types/react": "^18", "@types/react": "^18",
"**/zeed-dom": "0.10.9", "**/zeed-dom": "0.10.9",
"**/zod": "3.23.8",
"**/expo-constants": "16.0.1", "**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2", "**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1" "@react-native/babel-preset": "0.74.1"
@@ -1,5 +1,5 @@
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 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 --- 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 +++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() { @@ -42,7 +42,7 @@ class HapticsModule : Module() {
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 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 --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+++ b/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 { @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
+26
View File
@@ -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 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 index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+7
View File
@@ -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. 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 This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
module. 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.
+46 -39
View File
@@ -14,36 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {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 {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' 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 LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
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 { import {
Provider as SessionProvider, Provider as SessionProvider,
SessionAccount, SessionAccount,
useSession, useSession,
useSessionApi, useSessionApi,
} from 'state/session' } from '#/state/session'
import {Provider as ShellStateProvider} from 'state/shell' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import * as Toast from 'view/com/util/Toast' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from 'view/shell' import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
@@ -69,6 +73,9 @@ function InnerApp() {
try { try {
if (account) { if (account) {
await resumeSession(account) await resumeSession(account)
} else {
await initialize()
await tryFetchGates(undefined, 'prefer-fresh-gates')
} }
} catch (e) { } catch (e) {
logger.error(`session: resume failed`, {message: e}) logger.error(`session: resume failed`, {message: e})
@@ -105,10 +112,12 @@ function InnerApp() {
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<GestureHandlerRootView style={s.h100pct}> <MutedThreadsProvider>
<TestCtrls /> <GestureHandlerRootView style={s.h100pct}>
<Shell /> <TestCtrls />
</GestureHandlerRootView> <Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
@@ -147,21 +156,19 @@ function App() {
<SessionProvider> <SessionProvider>
<ShellStateProvider> <ShellStateProvider>
<PrefsStateProvider> <PrefsStateProvider>
<MutedThreadsProvider> <InvitesStateProvider>
<InvitesStateProvider> <ModalStateProvider>
<ModalStateProvider> <DialogStateProvider>
<DialogStateProvider> <LightboxStateProvider>
<LightboxStateProvider> <I18nProvider>
<I18nProvider> <PortalProvider>
<PortalProvider> <InnerApp />
<InnerApp /> </PortalProvider>
</PortalProvider> </I18nProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider> </PrefsStateProvider>
</ShellStateProvider> </ShellStateProvider>
</SessionProvider> </SessionProvider>
+36 -36
View File
@@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger' 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 {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' 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 LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
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 { import {
Provider as SessionProvider, Provider as SessionProvider,
SessionAccount, SessionAccount,
useSession, useSession,
useSessionApi, useSessionApi,
} from 'state/session' } from '#/state/session'
import {Provider as ShellStateProvider} from 'state/shell' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import * as Toast from 'view/com/util/Toast' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {ToastContainer} from 'view/com/util/Toast.web' import * as Toast from '#/view/com/util/Toast'
import {Shell} from 'view/shell/index' import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +96,11 @@ function InnerApp() {
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<Shell /> <SafeAreaProvider>
</SafeAreaProvider> <Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
@@ -136,21 +138,19 @@ function App() {
<SessionProvider> <SessionProvider>
<ShellStateProvider> <ShellStateProvider>
<PrefsStateProvider> <PrefsStateProvider>
<MutedThreadsProvider> <InvitesStateProvider>
<InvitesStateProvider> <ModalStateProvider>
<ModalStateProvider> <DialogStateProvider>
<DialogStateProvider> <LightboxStateProvider>
<LightboxStateProvider> <I18nProvider>
<I18nProvider> <PortalProvider>
<PortalProvider> <InnerApp />
<InnerApp /> </PortalProvider>
</PortalProvider> </I18nProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider> </PrefsStateProvider>
</ShellStateProvider> </ShellStateProvider>
</SessionProvider> </SessionProvider>
+3
View File
@@ -267,6 +267,9 @@ export const atoms = {
font_bold: { font_bold: {
fontWeight: tokens.fontWeight.bold, fontWeight: tokens.fontWeight.bold,
}, },
font_heavy: {
fontWeight: tokens.fontWeight.heavy,
},
italic: { italic: {
fontStyle: 'italic', fontStyle: 'italic',
}, },
+1
View File
@@ -118,6 +118,7 @@ export const fontWeight = {
normal: '400', normal: '400',
semibold: '500', semibold: '500',
bold: '600', bold: '600',
heavy: '700',
} as const } as const
export const gradients = { export const gradients = {
+205
View File
@@ -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 (
<Link feed={feed}>
<Outer>
<Header>
<Avatar src={feed.avatar} />
<TitleAndByline title={feed.displayName} creator={feed.creator} />
<Action uri={feed.uri} pin />
</Header>
<Description description={feed.description} />
<Likes count={feed.likeCount || 0} />
</Outer>
</Link>
)
}
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 <InternalLink to={href}>{children}</InternalLink>
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
}
export function Avatar({src}: {src: string | undefined}) {
return <UserAvatar type="algo" size={40} avatar={src} />
}
export function TitleAndByline({
title,
creator,
}: {
title: string
creator: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
</Text>
</View>
)
}
export function Description({description}: {description?: string}) {
const [rt, isResolving] = useRichText(description || '')
if (!description) return null
return isResolving ? (
<RichText value={description} style={[a.leading_snug]} />
) : (
<RichText value={rt} style={[a.leading_snug]} />
)
}
export function Likes({count}: {count: number}) {
const t = useTheme()
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{plural(count || 0, {
one: 'Liked by # user',
other: 'Liked by # users',
})}
</Text>
)
}
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
const {hasSession} = useSession()
if (!hasSession) return null
return <ActionInner uri={uri} pin={pin} />
}
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 (
<>
<Button
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
)}
</Button>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove this from your feeds?`,
)}
onConfirm={toggleSave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
)
}
+1 -1
View File
@@ -100,7 +100,7 @@ function KnownFollowersInner({
moderation, moderation,
} }
}) })
const count = cachedKnownFollowers.count - Math.min(slice.length, 2) const count = cachedKnownFollowers.count
return ( return (
<Link <Link
+85
View File
@@ -0,0 +1,85 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {Newskie} from '#/components/icons/Newskie'
import {Text} from '#/components/Typography'
export function NewskieDialog({
profile,
disabled,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
disabled?: boolean
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = useDialogControl()
const profileName = React.useMemo(() => {
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 (
<View style={[a.pr_2xs]}>
<Button
disabled={disabled}
label={_(
msg`This user is new here. Press for more info about when they joined.`,
)}
hitSlop={HITSLOP_10}
onPress={control.open}>
{({hovered, pressed}) => (
<Newskie
size="lg"
fill="#FFC404"
style={{
opacity: hovered || pressed ? 0.5 : 1,
}}
/>
)}
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Say hello!</Trans>
</Text>
<Text style={[a.text_md]}>
<Trans>
{profileName} joined Bluesky{' '}
{timeAgo(createdAt, now, {format: 'long'})} ago
</Trans>
</Text>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</View>
)
}
+15 -2
View File
@@ -51,10 +51,23 @@ const floatingMiddlewares = [
] ]
export function ProfileHoverCard(props: ProfileHoverCardProps) { 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) { if (props.disable || isTouchDevice) {
return props.children return props.children
} else { } else {
return <ProfileHoverCardInner {...props} /> return (
<View onPointerMove={onPointerMove}>
<ProfileHoverCardInner {...props} />
</View>
)
} }
} }
@@ -456,7 +469,7 @@ function Inner({
)} )}
</Text> </Text>
<ProfileHeaderHandle profile={profileShadow} /> <ProfileHeaderHandle profile={profileShadow} disableTaps />
</View> </View>
</Link> </Link>
+10 -7
View File
@@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' 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 * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -136,7 +136,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onPress: () => void onPress: ButtonProps['onPress']
color?: ButtonColor color?: ButtonColor
/** /**
* Optional i18n string. If undefined, it will default to "Confirm". * Optional i18n string. If undefined, it will default to "Confirm".
@@ -147,9 +147,12 @@ export function Action({
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => { const handleOnPress = React.useCallback(
close(onPress) (e: GestureResponderEvent) => {
}, [close, onPress]) close(() => onPress?.(e))
},
[close, onPress],
)
return ( return (
<Button <Button
@@ -186,7 +189,7 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onConfirm: () => void onConfirm: ButtonProps['onPress']
confirmButtonColor?: ButtonColor confirmButtonColor?: ButtonColor
showCancel?: boolean showCancel?: boolean
}>) { }>) {
+1 -1
View File
@@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
)} )}
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={leaveConvo} onConfirm={() => leaveConvo()}
/> />
) )
} }
+5 -2
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -49,7 +49,10 @@ export function DateField({
<DateFieldButton <DateFieldButton
label={label} label={label}
value={value} value={value}
onPress={control.open} onPress={() => {
Keyboard.dismiss()
control.open()
}}
isInvalid={isInvalid} isInvalid={isInvalid}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
/> />
+4
View File
@@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
}) })
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Newskie = createSinglePathSVG({
path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z',
})
+2
View File
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER
'Post:EditThreadgateOpened': {}
'Post:ThreadgateEdited': {}
// PROFILE events // PROFILE events
'Profile:Follow': { 'Profile:Follow': {
username: string username: string
+12 -5
View File
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res return res
} }
async function createThreadgate( export async function createThreadgate(
agent: BskyAgent, agent: BskyAgent,
postUri: string, postUri: string,
threadgate: ThreadgateSetting[], threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
} }
const postUrip = new AtUri(postUri) const postUrip = new AtUri(postUri)
await agent.api.app.bsky.feed.threadgate.create( await agent.api.com.atproto.repo.putRecord({
{repo: agent.session!.did, rkey: postUrip.rkey}, repo: agent.session!.did,
{post: postUri, createdAt: new Date().toISOString(), allow}, collection: 'app.bsky.feed.threadgate',
) rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
} }
// helpers // helpers
+102
View File
@@ -0,0 +1,102 @@
import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => {
it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
})
it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
})
it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
})
it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now')
})
it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
})
it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now')
})
it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s')
})
it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s')
})
it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
})
it(`minutes round down`, () => {
const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
})
it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m')
})
it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
})
it(`hours round down`, () => {
const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
})
it(`values < 1 day return hours`, () => {
const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h')
})
it(`values >= 1 day return days`, () => {
const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
})
it(`days round down`, () => {
const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
})
it(`values < 30 days return days`, () => {
const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d')
})
it(`values >= 30 days return months`, () => {
const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
})
it(`months round down`, () => {
const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
})
it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
})
it(`values < 360 days return months`, () => {
const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
})
it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
})
})
+86
View File
@@ -0,0 +1,86 @@
import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
export type TimeAgoOptions = {
lingui: I18nContext['_']
format?: 'long' | 'short'
}
export function useGetTimeAgo() {
const {_} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
}
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH_30 = DAY * 30
/**
* Returns the difference between `earlier` and `later` dates, formatted as a
* natural language string.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - Differences >= 360 days are returned as the "M/D/YYYY" string
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
options: TimeAgoOptions,
): string {
const _ = options.lingui
const format = options?.format || 'short'
const long = format === 'long'
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
if (diffSeconds < NOW) {
return _(msg`now`)
} else if (diffSeconds < MINUTE) {
return `${diffSeconds}${
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
}`
} else if (diffSeconds < HOUR) {
const diff = Math.floor(diffSeconds / MINUTE)
return `${diff}${
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
}`
} else if (diffSeconds < DAY) {
const diff = Math.floor(diffSeconds / HOUR)
return `${diff}${
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
}`
} else if (diffSeconds < MONTH_30) {
const diff = Math.floor(diffSeconds / DAY)
return `${diff}${
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
}`
} else {
const diff = Math.floor(diffSeconds / MONTH_30)
if (diff < 12) {
return `${diff}${
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
}`
} else {
const str = new Date(earlier).toLocaleDateString()
if (long) {
return _(msg`on ${str}`)
}
return str
}
}
}
+2
View File
@@ -103,6 +103,8 @@ export type LogEvents = {
'post:unrepost': { 'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
} }
'post:mute': {}
'post:unmute': {}
'profile:follow': { 'profile:follow': {
didBecomeMutual: boolean | undefined didBecomeMutual: boolean | undefined
followeeClout: number | undefined followeeClout: number | undefined
+1
View File
@@ -1,5 +1,6 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'native_pwi_disabled'
| 'request_notifications_permission_after_onboarding_v2' | 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button' | 'show_avi_follow_button'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
+29 -2
View File
@@ -14,6 +14,8 @@ import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {LogEvents} from './events' import {LogEvents} from './events'
import {Gate} from './gates' import {Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
type StatsigUser = { type StatsigUser = {
userID: string | undefined userID: string | undefined
// TODO: Remove when enough users have custom.platform: // TODO: Remove when enough users have custom.platform:
@@ -26,6 +28,8 @@ type StatsigUser = {
bundleDate: number bundleDate: number
refSrc: string refSrc: string
refUrl: string refUrl: string
referrer: string
referrerHostname: string
appLanguage: string appLanguage: string
contentLanguages: string[] contentLanguages: string[]
} }
@@ -33,12 +37,29 @@ type StatsigUser = {
let refSrc = '' let refSrc = ''
let refUrl = '' let refUrl = ''
let referrer = ''
let referrerHostname = ''
if (isWeb && typeof window !== 'undefined') { if (isWeb && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? '' refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '') refUrl = decodeURIComponent(params.get('ref_url') ?? '')
} }
if (
isWeb &&
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
referrer = document.referrer
referrerHostname = url.hostname
}
} catch {}
}
export type {LogEvents} export type {LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) { function createStatsigOptions(prefetchUsers: StatsigUser[]) {
@@ -198,6 +219,8 @@ function toStatsigUser(did: string | undefined): StatsigUser {
custom: { custom: {
refSrc, refSrc,
refUrl, refUrl,
referrer,
referrerHostname,
platform: Platform.OS as 'ios' | 'android' | 'web', platform: Platform.OS as 'ios' | 'android' | 'web',
bundleIdentifier: BUNDLE_IDENTIFIER, bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE, bundleDate: BUNDLE_DATE,
@@ -230,7 +253,7 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
}) })
export async function tryFetchGates( export async function tryFetchGates(
did: string, did: string | undefined,
strategy: 'prefer-low-latency' | 'prefer-fresh-gates', strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
) { ) {
try { try {
@@ -254,6 +277,10 @@ export async function tryFetchGates(
} }
} }
export function initialize() {
return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
}
export function Provider({children}: {children: React.ReactNode}) { export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
const did = currentAccount?.did const did = currentAccount?.did
@@ -299,7 +326,7 @@ export function Provider({children}: {children: React.ReactNode}) {
<GateCache.Provider value={gateCache}> <GateCache.Provider value={gateCache}>
<StatsigProvider <StatsigProvider
key={did} key={did}
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV" sdkKey={SDK_KEY}
mountKey={currentStatsigUser.userID} mountKey={currentStatsigUser.userID}
user={currentStatsigUser} user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above. // This isn't really blocking due to short initTimeoutMs above.
-42
View File
@@ -1,45 +1,3 @@
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH_30 = DAY * 30
const MONTH = DAY * 30.41675 // This results in 365.001 days in a year, which is close enough for nearly all cases
export function ago(date: number | string | Date): string {
let ts: number
if (typeof date === 'string') {
ts = Number(new Date(date))
} else if (date instanceof Date) {
ts = Number(date)
} else {
ts = date
}
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
if (diffSeconds < NOW) {
return `now`
} else if (diffSeconds < MINUTE) {
return `${diffSeconds}s`
} else if (diffSeconds < HOUR) {
return `${Math.floor(diffSeconds / MINUTE)}m`
} else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH_30) {
return `${Math.round(diffSeconds / DAY)}d`
} else {
let months = diffSeconds / MONTH
if (months % 1 >= 0.9) {
months = Math.ceil(months)
} else {
months = Math.floor(months)
}
if (months < 12) {
return `${months}mo`
} else {
return new Date(ts).toLocaleDateString()
}
}
}
export function niceDate(date: number | string | Date) { export function niceDate(date: number | string | Date) {
const d = new Date(date) const d = new Date(date)
return `${d.toLocaleDateString('en-us', { return `${d.toLocaleDateString('en-us', {
+1 -1
View File
@@ -1,5 +1,5 @@
import '@formatjs/intl-locale/polyfill' import '@formatjs/intl-locale/polyfill'
import '@formatjs/intl-pluralrules/polyfill' import '@formatjs/intl-pluralrules/polyfill-force' // Don't remove -force because detection is very slow
import '@formatjs/intl-pluralrules/locale-data/en' import '@formatjs/intl-pluralrules/locale-data/en'
import {useEffect} from 'react' import {useEffect} from 'react'
+1 -1
View File
@@ -152,7 +152,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
{children} {children}
</View> </View>
<View style={{height: 200}} /> <View style={{height: 400}} />
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
@@ -5,7 +5,7 @@ import ViewShot from 'react-native-view-shot'
import {useAvatar} from '#/screens/Onboarding/StepProfile/index' import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
const SIZE_MULTIPLIER = 1.5 const SIZE_MULTIPLIER = 5
export interface PlaceholderCanvasRef { export interface PlaceholderCanvasRef {
capture: () => Promise<string> capture: () => Promise<string>
+8 -1
View File
@@ -5,19 +5,26 @@ import {Trans} from '@lingui/macro'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {isInvalidHandle} from 'lib/strings/handles' import {isInvalidHandle} from 'lib/strings/handles'
import {isAndroid} from 'platform/detection'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {NewskieDialog} from '#/components/NewskieDialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function ProfileHeaderHandle({ export function ProfileHeaderHandle({
profile, profile,
disableTaps,
}: { }: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
disableTaps?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const invalidHandle = isInvalidHandle(profile.handle) const invalidHandle = isInvalidHandle(profile.handle)
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
return ( return (
<View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none"> <View
style={[a.flex_row, a.gap_xs, a.align_center]}
pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
<NewskieDialog profile={profile} disabled={disableTaps} />
{profile.viewer?.followedBy && !blockHide ? ( {profile.viewer?.followedBy && !blockHide ? (
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
<Text style={[t.atoms.text, a.text_sm]}> <Text style={[t.atoms.text, a.text_sm]}>
@@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
const canSubscribe = const canSubscribe =
isSubscribed || isSubscribed ||
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} = const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation() useUnlikeMutation()
@@ -328,12 +328,12 @@ function CantSubscribePrompt({
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText> <Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
<Prompt.DescriptionText> <Prompt.DescriptionText>
<Trans> <Trans>
We're sorry! You can only subscribe to ten labelers, and you've We're sorry! You can only subscribe to twenty labelers, and you've
reached your limit of ten. reached your limit of twenty.
</Trans> </Trans>
</Prompt.DescriptionText> </Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} />
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
) )
+2
View File
@@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates' import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '../queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
@@ -111,4 +112,5 @@ function* findProfilesInCache(
yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadQueryData(queryClient, did) yield* findAllProfilesInPostThreadQueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
} }
+97
View File
@@ -0,0 +1,97 @@
import React, {useEffect} from 'react'
import * as persisted from '#/state/persisted'
import {useAgent, useSession} from '../session'
type StateContext = Map<string, boolean>
type SetStateContext = (uri: string, value: boolean) => void
const stateContext = React.createContext<StateContext>(new Map())
const setStateContext = React.createContext<SetStateContext>(
(_: string) => false,
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState<StateContext>(() => new Map())
const setThreadMute = React.useCallback(
(uri: string, value: boolean) => {
setState(prev => {
const next = new Map(prev)
next.set(uri, value)
return next
})
},
[setState],
)
useMigrateMutes(setThreadMute)
return (
<stateContext.Provider value={state}>
<setStateContext.Provider value={setThreadMute}>
{children}
</setStateContext.Provider>
</stateContext.Provider>
)
}
export function useMutedThreads() {
return React.useContext(stateContext)
}
export function useIsThreadMuted(uri: string, defaultValue = false) {
const state = React.useContext(stateContext)
return state.get(uri) ?? defaultValue
}
export function useSetThreadMute() {
return React.useContext(setStateContext)
}
function useMigrateMutes(setThreadMute: SetStateContext) {
const agent = useAgent()
const {currentAccount} = useSession()
useEffect(() => {
if (currentAccount) {
if (
!persisted
.get('mutedThreads')
.some(uri => uri.includes(currentAccount.did))
) {
return
}
let cancelled = false
const migrate = async () => {
while (!cancelled) {
const threads = persisted.get('mutedThreads')
const root = threads.findLast(uri => uri.includes(currentAccount.did))
if (!root) break
persisted.write(
'mutedThreads',
threads.filter(uri => uri !== root),
)
setThreadMute(root, true)
await agent.api.app.bsky.graph
.muteThread({root})
// not a big deal if this fails, since the post might have been deleted
.catch(console.error)
}
}
migrate()
return () => {
cancelled = true
}
}
}, [agent, currentAccount, setThreadMute])
}
+2 -1
View File
@@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal { export interface ThreadgateModal {
name: 'threadgate' name: 'threadgate'
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
} }
export interface ChangeHandleModal { export interface ChangeHandleModal {
-62
View File
@@ -1,62 +0,0 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {track} from '#/lib/analytics/analytics'
type StateContext = persisted.Schema['mutedThreads']
type ToggleContext = (uri: string) => boolean
const stateContext = React.createContext<StateContext>(
persisted.defaults.mutedThreads,
)
const toggleContext = React.createContext<ToggleContext>((_: string) => false)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('mutedThreads'))
const toggleThreadMute = React.useCallback(
(uri: string) => {
let muted = false
setState((arr: string[]) => {
if (arr.includes(uri)) {
arr = arr.filter(v => v !== uri)
muted = false
track('Post:ThreadUnmute')
} else {
arr = arr.concat([uri])
muted = true
track('Post:ThreadMute')
}
persisted.write('mutedThreads', arr)
return arr
})
return muted
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('mutedThreads'))
})
}, [setState])
return (
<stateContext.Provider value={state}>
<toggleContext.Provider value={toggleThreadMute}>
{children}
</toggleContext.Provider>
</stateContext.Provider>
)
}
export function useMutedThreads() {
return React.useContext(stateContext)
}
export function useToggleThreadMute() {
return React.useContext(toggleContext)
}
export function isThreadMuted(uri: string) {
return persisted.get('mutedThreads').includes(uri)
}
+2 -1
View File
@@ -74,7 +74,6 @@ export const schema = z.object({
flickr: z.enum(externalEmbedOptions).optional(), flickr: z.enum(externalEmbedOptions).optional(),
}) })
.optional(), .optional(),
mutedThreads: z.array(z.string()), // should move to server
invites: z.object({ invites: z.object({
copiedInvites: z.array(z.string()), copiedInvites: z.array(z.string()),
}), }),
@@ -88,6 +87,8 @@ export const schema = z.object({
disableHaptics: z.boolean().optional(), disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(), disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(), kawaii: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
+127 -5
View File
@@ -1,3 +1,4 @@
import {useCallback, useEffect, useMemo, useRef} from 'react'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyFeedDefs, AppBskyFeedDefs,
@@ -171,28 +172,121 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
}) })
} }
export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] // 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
export 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 useGetPopularFeedsQuery() { type GetPopularFeedsOptions = {limit?: number}
export function createGetPopularFeedsQueryKey(
options?: GetPopularFeedsOptions,
) {
return ['getPopularFeeds', options]
}
export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
const {hasSession} = useSession()
const agent = useAgent() const agent = useAgent()
return useInfiniteQuery< const limit = options?.limit || 10
const {data: preferences} = usePreferencesQuery()
// Make sure this doesn't invalidate unless really needed.
const selectArgs = useMemo(
() => ({
hasSession,
savedFeeds: preferences?.savedFeeds || [],
}),
[hasSession, preferences?.savedFeeds],
)
const lastPageCountRef = useRef(0)
const query = useInfiniteQuery<
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema, AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
Error, Error,
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
QueryKey, QueryKey,
string | undefined string | undefined
>({ >({
queryKey: useGetPopularFeedsQueryKey, queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: 10, limit,
cursor: pageParam, cursor: pageParam,
}) })
return res.data return res.data
}, },
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
select: useCallback(
(
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
) => {
const {savedFeeds, hasSession: hasSessionInner} = selectArgs
return {
...data,
pages: data.pages.map(page => {
return {
...page,
feeds: page.feeds.filter(feed => {
if (
!hasSessionInner &&
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
) {
return false
}
const alreadySaved = Boolean(
savedFeeds?.find(f => {
return f.value === feed.uri
}),
)
return !alreadySaved
}),
}
}),
}
},
[selectArgs /* Don't change. Everything needs to go into selectArgs. */],
),
}) })
useEffect(() => {
const {isFetching, hasNextPage, data} = query
if (isFetching || !hasNextPage) {
return
}
// avoid double-fires of fetchNextPage()
if (
lastPageCountRef.current !== 0 &&
lastPageCountRef.current === data?.pages?.length
) {
return
}
// fetch next page if we haven't gotten a full page of content
let count = 0
for (const page of data?.pages || []) {
count += page.feeds.length
}
if (count < limit && (data?.pages.length || 0) < 6) {
query.fetchNextPage()
lastPageCountRef.current = data?.pages?.length || 0
}
}, [query, limit])
return query
} }
export function useSearchPopularFeedsMutation() { export function useSearchPopularFeedsMutation() {
@@ -209,6 +303,34 @@ export function useSearchPopularFeedsMutation() {
}) })
} }
const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
export const createPopularFeedsSearchQueryKey = (query: string) => [
popularFeedsSearchQueryKeyRoot,
query,
]
export function usePopularFeedsSearch({
query,
enabled,
}: {
query: string
enabled?: boolean
}) {
const agent = useAgent()
return useQuery({
enabled,
queryKey: createPopularFeedsSearchQueryKey(query),
queryFn: async () => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
limit: 10,
query: query,
})
return res.data.feeds
},
})
}
export type SavedFeedSourceInfo = FeedSourceInfo & { export type SavedFeedSourceInfo = FeedSourceInfo & {
savedFeed: AppBskyActorDefs.SavedFeed savedFeed: AppBskyActorDefs.SavedFeed
} }
+30 -2
View File
@@ -1,5 +1,10 @@
import {AppBskyGraphGetKnownFollowers} from '@atproto/api' import {AppBskyActorDefs, AppBskyGraphGetKnownFollowers} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' import {
InfiniteData,
QueryClient,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@@ -32,3 +37,26 @@ export function useProfileKnownFollowersQuery(did: string | undefined) {
enabled: !!did, enabled: !!did,
}) })
} }
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const follow of page.followers) {
if (follow.did === did) {
yield follow
}
}
}
}
}
-3
View File
@@ -26,7 +26,6 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..' import {STALE} from '..'
@@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const lastPageCountRef = useRef(0) const lastPageCountRef = useRef(0)
@@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
cursor: pageParam, cursor: pageParam,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
fetchAdditionalData: true, fetchAdditionalData: true,
}) })
).page ).page
+1 -4
View File
@@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications' import {resetBadgeCount} from 'lib/notifications/notifications'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('') const [numUnread, setNumUnread] = React.useState('')
@@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
limit: 40, limit: 40,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
// only fetch subjects when the page is going to be used // only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it // in the notifications query, otherwise skip it
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, },
} }
}, [setNumUnread, queryClient, moderationOpts, threadMutes, agent]) }, [setNumUnread, queryClient, moderationOpts, agent])
checkUnreadRef.current = api.checkUnread checkUnreadRef.current = api.checkUnread
return ( return (
-50
View File
@@ -1,5 +1,4 @@
import { import {
AppBskyEmbedRecord,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedLike, AppBskyFeedLike,
AppBskyFeedPost, AppBskyFeedPost,
@@ -28,7 +27,6 @@ export async function fetchPage({
limit, limit,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
fetchAdditionalData, fetchAdditionalData,
}: { }: {
agent: BskyAgent agent: BskyAgent
@@ -36,7 +34,6 @@ export async function fetchPage({
limit: number limit: number
queryClient: QueryClient queryClient: QueryClient
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
threadMutes: string[]
fetchAdditionalData: boolean fetchAdditionalData: boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> { }): Promise<{page: FeedPage; indexedAt: string | undefined}> {
const res = await agent.listNotifications({ const res = await agent.listNotifications({
@@ -67,11 +64,6 @@ export async function fetchPage({
} }
} }
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date() let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
if (Number.isNaN(seenAt.getTime())) { if (Number.isNaN(seenAt.getTime())) {
seenAt = new Date() seenAt = new Date()
@@ -207,45 +199,3 @@ function getSubjectUri(
return notif.reasonSubject return notif.reasonSubject
} }
} }
export function isThreadMuted(notif: FeedNotification, threadMutes: string[]) {
// If there's a subject we want to use that. This will always work on the notifications tab
if (notif.subject) {
const record = notif.subject.record as AppBskyFeedPost.Record
// Check for a quote record
if (
(record.reply && threadMutes.includes(record.reply.root.uri)) ||
(notif.subject.uri && threadMutes.includes(notif.subject.uri))
) {
return true
} else if (
AppBskyEmbedRecord.isMain(record.embed) &&
threadMutes.includes(record.embed.record.uri)
) {
return true
}
} else {
// Otherwise we just do the best that we can
const record = notif.notification.record
if (AppBskyFeedPost.isRecord(record)) {
if (record.reply && threadMutes.includes(record.reply.root.uri)) {
// We can always filter replies
return true
} else if (
AppBskyEmbedRecord.isMain(record.embed) &&
threadMutes.includes(record.embed.record.uri)
) {
// We can also filter quotes if the quoted post is the root
return true
}
} else if (
AppBskyFeedRepost.isRecord(record) &&
threadMutes.includes(record.subject.uri)
) {
// Finally we can filter reposts, again if the post is the root
return true
}
}
return false
}
+12 -3
View File
@@ -31,7 +31,8 @@ import {
getEmbeddedPost, getEmbeddedPost,
} from './util' } from './util'
const RQKEY_ROOT = 'post-thread' const REPLY_TREE_DEPTH = 10
export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) {
gcTime: 0, gcTime: 0,
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
const res = await agent.getPostThread({uri: uri!, depth: 10}) const res = await agent.getPostThread({
uri: uri!,
depth: REPLY_TREE_DEPTH,
})
if (res.success) { if (res.success) {
const thread = responseToThreadNodes(res.data.thread) const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread) annotateSelfThread(thread)
@@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) {
selfThreadNode.ctx.isSelfThread = true selfThreadNode.ctx.isSelfThread = true
} }
const last = selfThreadNodes[selfThreadNodes.length - 1] const last = selfThreadNodes[selfThreadNodes.length - 1]
if (last && last.post.replyCount && !last.replies?.length) { if (
last &&
last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
last.post.replyCount && // has replies
!last.replies?.length // replies were not hydrated
) {
last.ctx.hasMoreSelfThread = true last.ctx.hasMoreSelfThread = true
} }
} }
+70
View File
@@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile' import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post' const RQKEY_ROOT = 'post'
@@ -291,3 +292,72 @@ export function usePostDeleteMutation() {
}, },
}) })
} }
export function useThreadMuteMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
rootUri: string,
) {
const threadMuteMutation = useThreadMuteMutation()
const threadUnmuteMutation = useThreadUnmuteMutation()
const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
const setThreadMute = useSetThreadMute()
const queueToggle = useToggleMutationQueue<boolean>({
initialState: isThreadMuted,
runMutation: async (_prev, shouldMute) => {
if (shouldMute) {
await threadMuteMutation.mutateAsync({
uri: rootUri,
})
return true
} else {
await threadUnmuteMutation.mutateAsync({
uri: rootUri,
})
return false
}
},
onSuccess(finalIsMuted) {
// finalize
setThreadMute(rootUri, finalIsMuted)
},
})
const queueMuteThread = useCallback(() => {
// optimistically update
setThreadMute(rootUri, true)
return queueToggle(true)
}, [setThreadMute, rootUri, queueToggle])
const queueUnmuteThread = useCallback(() => {
// optimistically update
setThreadMute(rootUri, false)
return queueToggle(false)
}, [rootUri, setThreadMute, queueToggle])
return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const
}
function useThreadMuteMutation() {
const agent = useAgent()
return useMutation<
{},
Error,
{uri: string} // the root post's uri
>({
mutationFn: ({uri}) => {
logEvent('post:mute', {})
return agent.api.app.bsky.graph.muteThread({root: uri})
},
})
}
function useThreadUnmuteMutation() {
const agent = useAgent()
return useMutation<{}, Error, {uri: string}>({
mutationFn: ({uri}) => {
logEvent('post:unmute', {})
return agent.api.app.bsky.graph.unmuteThread({root: uri})
},
})
}
+9 -4
View File
@@ -23,7 +23,10 @@ import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts' import {useModerationOpts} from '../preferences/moderation-opts'
const suggestedFollowsQueryKeyRoot = 'suggested-follows' const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [
suggestedFollowsQueryKeyRoot,
options,
]
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor' const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
const suggestedFollowsByActorQueryKey = (did: string) => [ const suggestedFollowsByActorQueryKey = (did: string) => [
@@ -31,7 +34,9 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
did, did,
] ]
export function useSuggestedFollowsQuery() { type SuggestedFollowsOptions = {limit?: number}
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -46,12 +51,12 @@ export function useSuggestedFollowsQuery() {
>({ >({
enabled: !!moderationOpts && !!preferences, enabled: !!moderationOpts && !!preferences,
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: suggestedFollowsQueryKey, queryKey: suggestedFollowsQueryKey(options),
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const contentLangs = getContentLanguages().join(',') const contentLangs = getContentLanguages().join(',')
const res = await agent.app.bsky.actor.getSuggestions( const res = await agent.app.bsky.actor.getSuggestions(
{ {
limit: 25, limit: options?.limit || 25,
cursor: pageParam, cursor: pageParam,
}, },
{ {
+33
View File
@@ -1,5 +1,38 @@
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
export type ThreadgateSetting = export type ThreadgateSetting =
| {type: 'nobody'} | {type: 'nobody'}
| {type: 'mention'} | {type: 'mention'}
| {type: 'following'} | {type: 'following'}
| {type: 'list'; list: string} | {type: 'list'; list: string}
export function threadgateViewToSettings(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
): ThreadgateSetting[] {
const record =
threadgate &&
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
? threadgate.record
: null
if (!record) {
return []
}
if (!record.allow?.length) {
return [{type: 'nobody'}]
}
return record.allow
.map(allow => {
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
return {type: 'mention'}
}
if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
return {type: 'following'}
}
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
return {type: 'list', list: allow.list}
}
return undefined
})
.filter(Boolean) as ThreadgateSetting[]
}
+2 -1
View File
@@ -57,6 +57,7 @@ function HomeHeaderLayoutDesktopAndTablet({
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
styles.bar, styles.bar,
kawaii && {paddingTop: 22, paddingBottom: 16},
]}> ]}>
<View <View
style={[ style={[
@@ -66,7 +67,7 @@ function HomeHeaderLayoutDesktopAndTablet({
a.m_auto, a.m_auto,
kawaii && {paddingTop: 4, paddingBottom: 0}, kawaii && {paddingTop: 4, paddingBottom: 0},
{ {
width: kawaii ? 60 : 28, width: kawaii ? 84 : 28,
}, },
]}> ]}>
<Logo width={kawaii ? 60 : 28} /> <Logo width={kawaii ? 60 : 28} />
+7 -4
View File
@@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({ export function Component({
settings, settings,
onChange, onChange,
onConfirm,
}: { }: {
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
@@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => { const onPressEverybody = () => {
setSelected([]) setSelected([])
onChange([]) onChange?.([])
} }
const onPressNobody = () => { const onPressNobody = () => {
setSelected([{type: 'nobody'}]) setSelected([{type: 'nobody'}])
onChange([{type: 'nobody'}]) onChange?.([{type: 'nobody'}])
} }
const onPressAudience = (setting: ThreadgateSetting) => { const onPressAudience = (setting: ThreadgateSetting) => {
@@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1) newSelected.splice(i, 1)
} }
setSelected(newSelected) setSelected(newSelected)
onChange(newSelected) onChange?.(newSelected)
} }
return ( return (
@@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn" testID="confirmBtn"
onPress={() => { onPress={() => {
closeModal() closeModal()
onConfirm?.(selected)
}} }}
style={styles.btn} style={styles.btn}
accessibilityRole="button" accessibilityRole="button"
+23 -2
View File
@@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers' import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`) const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author) const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle const authorTitle = post.author.handle
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => { const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
</View> </View>
</View> </View>
</View> </View>
<WhoCanReply post={post} /> <WhoCanReply
post={post}
isThreadAuthor={isThreadAuthor}
style={{borderBottomWidth: isNative ? 1 : 0}}
/>
</> </>
) )
} else { } else {
@@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post} post={post}
style={{ style={{
marginTop: 4, marginTop: 4,
borderBottomWidth: 1,
}} }}
isThreadAuthor={isThreadAuthor}
/> />
</> </>
) )
@@ -681,6 +688,20 @@ function ExpandedPostDetails({
) )
} }
function getThreadAuthor(
post: AppBskyFeedDefs.PostView,
record: AppBskyFeedPost.Record,
): string {
if (!record.reply) {
return post.author.did
}
try {
return new AtUri(record.reply.root.uri).host
} catch {
return ''
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: hairlineWidth, borderTopWidth: hairlineWidth,
+142 -98
View File
@@ -1,128 +1,172 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import { import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
AppBskyFeedDefs, import {msg, Trans} from '@lingui/macro'
AppBskyFeedThreadgate, import {useLingui} from '@lingui/react'
AppBskyGraphDefs, import {useQueryClient} from '@tanstack/react-query'
AtUri,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles' import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {Button} from '#/components/Button'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
export function WhoCanReply({ export function WhoCanReply({
post, post,
isThreadAuthor,
style, style,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {track} = useAnalytics()
const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle( const containerStyles = useColorSchemeStyle(
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
) )
const iconStyles = useColorSchemeStyle(
{
backgroundColor: colors.blue3,
},
{
backgroundColor: colors.blue3,
},
)
const textStyles = useColorSchemeStyle( const textStyles = useColorSchemeStyle(
{color: colors.gray7}, {color: colors.blue5},
{color: colors.blue1}, {color: colors.blue1},
) )
const record = React.useMemo( const hoverStyles = useColorSchemeStyle(
() => {
post.threadgate && backgroundColor: colors.white,
AppBskyFeedThreadgate.isRecord(post.threadgate.record) && },
AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success {
? post.threadgate.record backgroundColor: pal.colors.background,
: null, },
)
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post], [post],
) )
if (record) { const isRootPost = !('reply' in post.record)
return (
<View const onPressEdit = () => {
style={[ track('Post:EditThreadgateOpened')
{ if (isNative && Keyboard.isVisible()) {
flexDirection: 'row', Keyboard.dismiss()
alignItems: 'center', }
gap: isMobile ? 8 : 10, openModal({
paddingHorizontal: isMobile ? 16 : 18, name: 'threadgate',
paddingVertical: 12, settings,
borderWidth: 1, async onConfirm(newSettings: ThreadgateSetting[]) {
borderLeftWidth: isMobile ? 0 : 1, try {
borderRightWidth: isMobile ? 0 : 1, if (newSettings.length) {
}, await createThreadgate(agent, post.uri, newSettings)
containerStyles, } else {
style, await agent.api.com.atproto.repo.deleteRecord({
]}> repo: agent.session!.did,
<View collection: 'app.bsky.feed.threadgate',
style={[ rkey: new AtUri(post.uri).rkey,
{ })
flexDirection: 'row', }
alignItems: 'center', Toast.show('Thread settings updated')
justifyContent: 'center', queryClient.invalidateQueries({
width: 32, queryKey: [POST_THREAD_RQKEY_ROOT],
height: 32, })
borderRadius: 19, track('Post:ThreadgateEdited')
}, } catch (err) {
iconStyles, Toast.show(
]}> 'There was an issue. Please check your internet connection and try again.',
<FontAwesomeIcon )
icon={['far', 'comments']} logger.error('Failed to edit threadgate', {message: err})
size={16} }
color={'#fff'} },
/> })
</View>
<View style={{flex: 1}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!record.allow?.length ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{record.allow.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator
key={`sep-${i}`}
i={i}
length={record.allow!.length}
/>
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
</View>
)
} }
return null
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingLeft: 18,
paddingRight: 14,
paddingVertical: 10,
borderTopWidth: 1,
},
pal.border,
containerStyles,
style,
]}>
<View style={{flex: 1, paddingVertical: 6}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!settings.length ? (
<Trans>Everybody can reply.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
{isThreadAuthor && (
<View>
<Button label={_(msg`Edit`)} onPress={onPressEdit}>
{({hovered}) => (
<View
style={[
hovered && hoverStyles,
{paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
]}>
<Text type="sm" style={pal.link}>
<Trans>Edit</Trans>
</Text>
</View>
)}
</Button>
</View>
)}
</View>
)
} }
function Rule({ function Rule({
@@ -130,15 +174,15 @@ function Rule({
post, post,
lists, lists,
}: { }: {
rule: any rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
if (AppBskyFeedThreadgate.isMentionRule(rule)) { if (rule.type === 'mention') {
return <Trans>mentioned users</Trans> return <Trans>mentioned users</Trans>
} }
if (AppBskyFeedThreadgate.isFollowingRule(rule)) { if (rule.type === 'following') {
return ( return (
<Trans> <Trans>
users followed by{' '} users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
</Trans> </Trans>
) )
} }
if (AppBskyFeedThreadgate.isListRule(rule)) { if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list) const list = lists?.find(l => l.uri === rule.list)
if (list) { if (list) {
const listUrip = new AtUri(list.uri) const listUrip = new AtUri(list.uri)
+31 -46
View File
@@ -3,15 +3,14 @@ import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query' 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 {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
import {isAndroid, isWeb} from 'platform/detection' import {isAndroid} from 'platform/detection'
import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TextLinkOnWebOnly} from './Link' import {TextLinkOnWebOnly} from './Link'
import {Text} from './text/Text' import {Text} from './text/Text'
@@ -37,17 +36,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle const handle = opts.author.handle
const prefetchProfileQuery = usePrefetchProfileQuery()
const profileLink = makeProfileLink(opts.author) 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 queryClient = useQueryClient()
const onOpenAuthor = opts.onOpenAuthor const onOpenAuthor = opts.onOpenAuthor
const onBeforePressAuthor = useCallback(() => { const onBeforePressAuthor = useCallback(() => {
@@ -71,39 +60,35 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
</View> </View>
)} )}
<ProfileHoverCard inline did={opts.author.did}> <ProfileHoverCard inline did={opts.author.did}>
<View <Text
onPointerMove={isWeb ? onPointerMove : undefined} numberOfLines={1}
style={[a.flex_1]}> style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
<Text <TextLinkOnWebOnly
numberOfLines={1} type={opts.displayNameType || 'lg-bold'}
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> style={[pal.text]}
<TextLinkOnWebOnly lineHeight={1.2}
type={opts.displayNameType || 'lg-bold'} disableMismatchWarning
style={[pal.text]} text={
lineHeight={1.2} <>
disableMismatchWarning {sanitizeDisplayName(
text={ displayName,
<> opts.moderation?.ui('displayName'),
{sanitizeDisplayName( )}
displayName, </>
opts.moderation?.ui('displayName'), }
)} href={profileLink}
</> onBeforePress={onBeforePressAuthor}
} />
href={profileLink} <TextLinkOnWebOnly
onBeforePress={onBeforePressAuthor} type="md"
/> disableMismatchWarning
<TextLinkOnWebOnly style={[pal.textLight, {flexShrink: 4}]}
type="md" text={'\xa0' + sanitizeHandle(handle, '@')}
disableMismatchWarning href={profileLink}
style={[pal.textLight, {flexShrink: 4}]} onBeforePress={onBeforePressAuthor}
text={'\xa0' + sanitizeHandle(handle, '@')} anchorNoUnderline
href={profileLink} />
onBeforePress={onBeforePressAuthor} </Text>
anchorNoUnderline
/>
</Text>
</View>
</ProfileHoverCard> </ProfileHoverCard>
{!isAndroid && ( {!isAndroid && (
<Text <Text
+6 -4
View File
@@ -1,26 +1,28 @@
import React from 'react' import React from 'react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
import {ago} from 'lib/strings/time'
export function TimeElapsed({ export function TimeElapsed({
timestamp, timestamp,
children, children,
timeToString = ago, timeToString,
}: { }: {
timestamp: string timestamp: string
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
timeToString?: (timeElapsed: string) => string timeToString?: (timeElapsed: string) => string
}) { }) {
const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() => const [timeElapsed, setTimeAgo] = React.useState(() =>
timeToString(timestamp), format(timestamp, tick),
) )
const [prevTick, setPrevTick] = React.useState(tick) const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) { if (prevTick !== tick) {
setPrevTick(tick) setPrevTick(tick)
setTimeAgo(timeToString(timestamp)) setTimeAgo(format(timestamp, tick))
} }
return children({timeElapsed}) return children({timeElapsed})
+26 -19
View File
@@ -7,7 +7,7 @@ import {
} from 'react-native' } from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
AppBskyActorDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
RichText as RichTextAPI, RichText as RichTextAPI,
@@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' 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 {useSession} from '#/state/session'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
@@ -62,9 +65,7 @@ import * as Toast from '../Toast'
let PostDropdownBtn = ({ let PostDropdownBtn = ({
testID, testID,
postAuthor, post,
postCid,
postUri,
postFeedContext, postFeedContext,
record, record,
richText, richText,
@@ -74,9 +75,7 @@ let PostDropdownBtn = ({
timestamp, timestamp,
}: { }: {
testID: string testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic post: Shadow<AppBskyFeedDefs.PostView>
postCid: string
postUri: string
postFeedContext: string | undefined postFeedContext: string | undefined
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI richText: RichTextAPI
@@ -92,8 +91,6 @@ let PostDropdownBtn = ({
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl const defaultCtrlColor = theme.palette.default.postCtrl
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation() const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts() const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi() const {hidePost} = useHiddenPostsApi()
@@ -107,9 +104,15 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl() const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl() const sendViaChatControl = useDialogControl()
const postUri = post.uri
const postCid = post.cid
const postAuthor = post.author
const rootUri = record.reply?.root?.uri || postUri 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 isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did const isAuthor = postAuthor.did === currentAccount?.did
@@ -162,18 +165,22 @@ let PostDropdownBtn = ({
const onToggleThreadMute = React.useCallback(() => { const onToggleThreadMute = React.useCallback(() => {
try { try {
const muted = toggleThreadMute(rootUri) if (isThreadMuted) {
if (muted) { unmuteThread()
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
Toast.show( Toast.show(
_(msg`You will no longer receive notifications for this thread`), _(msg`You will no longer receive notifications for this thread`),
) )
} else {
Toast.show(_(msg`You will now receive notifications for this thread`))
} }
} catch (e) { } catch (e: any) {
logger.error('Failed to toggle thread mute', {message: e}) 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 onCopyPostText = React.useCallback(() => {
const str = richTextToString(richText, true) const str = richTextToString(richText, true)
@@ -3,6 +3,7 @@ import {ScrollView, StyleSheet, View} from 'react-native'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -29,13 +30,18 @@ export const LoggedOutLayout = ({
borderLeftWidth: 1, borderLeftWidth: 1,
}) })
const [isKeyboardVisible] = useIsKeyboardVisible()
if (isMobile) { if (isMobile) {
if (scrollable) { if (scrollable) {
return ( return (
<ScrollView <ScrollView
style={styles.scrollview} style={styles.scrollview}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"> keyboardDismissMode="none"
contentContainerStyle={[
{paddingBottom: isKeyboardVisible ? 300 : 0},
]}>
<View style={a.pt_md}>{children}</View> <View style={a.pt_md}>{children}</View>
</ScrollView> </ScrollView>
) )
+1 -3
View File
@@ -319,9 +319,7 @@ let PostCtrls = ({
<View style={big ? a.align_center : [a.flex_1, a.align_start]}> <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostDropdownBtn <PostDropdownBtn
testID="postDropdownBtn" testID="postDropdownBtn"
postAuthor={post.author} post={post}
postCid={post.cid}
postUri={post.uri}
postFeedContext={feedContext} postFeedContext={feedContext}
record={record} record={record}
richText={richText} richText={richText}
+1 -1
View File
@@ -181,7 +181,7 @@ function AltText({text}: {text: string}) {
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action <Prompt.Action
onPress={control.close} onPress={() => control.close()}
cta={_(msg`Close`)} cta={_(msg`Close`)}
color="secondary" color="secondary"
/> />
+3 -3
View File
@@ -1,5 +1,5 @@
import {library} from '@fortawesome/fontawesome-svg-core' 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 {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark' import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' 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 {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' 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 {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown'
import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft' import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight' 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 {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' 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 {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' 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 {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' 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 {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
+17 -51
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {FAB} from 'view/com/util/fab/FAB' import {FAB} from 'view/com/util/fab/FAB'
import {SearchInput} from 'view/com/util/forms/SearchInput' 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 {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {Divider} from '#/components/Divider'
import * as FeedCard from '#/components/FeedCard'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@@ -94,6 +95,7 @@ type FlatlistSlice =
type: 'popularFeed' type: 'popularFeed'
key: string key: string
feedUri: string feedUri: string
feed: AppBskyFeedDefs.GeneratorView
} }
| { | {
type: 'popularFeedsLoadingMore' type: 'popularFeedsLoadingMore'
@@ -104,22 +106,6 @@ type FlatlistSlice =
key: string 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) { export function FeedsScreen(_props: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
@@ -316,6 +302,7 @@ export function FeedsScreen(_props: Props) {
key: `popularFeed:${feed.uri}`, key: `popularFeed:${feed.uri}`,
type: 'popularFeed', type: 'popularFeed',
feedUri: feed.uri, feedUri: feed.uri,
feed,
})), })),
) )
} }
@@ -327,10 +314,7 @@ export function FeedsScreen(_props: Props) {
type: 'popularFeedsLoading', type: 'popularFeedsLoading',
}) })
} else { } else {
if ( if (!popularFeeds?.pages) {
!popularFeeds?.pages ||
popularFeeds?.pages[0]?.feeds?.length === 0
) {
slices.push({ slices.push({
key: 'popularFeedsNoResults', key: 'popularFeedsNoResults',
type: 'popularFeedsNoResults', type: 'popularFeedsNoResults',
@@ -338,26 +322,12 @@ export function FeedsScreen(_props: Props) {
} else { } else {
for (const page of popularFeeds.pages || []) { for (const page of popularFeeds.pages || []) {
slices = slices.concat( slices = slices.concat(
page.feeds page.feeds.map(feed => ({
.filter(feed => { key: `popularFeed:${feed.uri}`,
if ( type: 'popularFeed',
!hasSession && feedUri: feed.uri,
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) feed,
) { })),
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,
})),
) )
} }
@@ -495,7 +465,7 @@ export function FeedsScreen(_props: Props) {
return ( return (
<> <>
<FeedsAboutHeader /> <FeedsAboutHeader />
<View style={{paddingHorizontal: 12, paddingBottom: 12}}> <View style={{paddingHorizontal: 12, paddingBottom: 4}}>
<SearchInput <SearchInput
query={query} query={query}
onChangeQuery={onChangeQuery} onChangeQuery={onChangeQuery}
@@ -510,13 +480,10 @@ export function FeedsScreen(_props: Props) {
return <FeedFeedLoadingPlaceholder /> return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') { } else if (item.type === 'popularFeed') {
return ( return (
<FeedSourceCard <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
feedUri={item.feedUri} <FeedCard.Default feed={item.feed} />
showSaveBtn={hasSession} <Divider />
showDescription </View>
showLikes
pinOnSave
/>
) )
} else if (item.type === 'popularFeedsNoResults') { } else if (item.type === 'popularFeedsNoResults') {
return ( return (
@@ -559,7 +526,6 @@ export function FeedsScreen(_props: Props) {
onPressCancelSearch, onPressCancelSearch,
onSubmitQuery, onSubmitQuery,
onChangeSearchFocus, onChangeSearchFocus,
hasSession,
], ],
) )
+15 -11
View File
@@ -1,18 +1,20 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 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 {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 {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< export function LogScreen({}: NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -22,6 +24,8 @@ export function LogScreen({}: NativeStackScreenProps<
const {_} = useLingui() const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = React.useState<string[]>([]) const [expanded, setExpanded] = React.useState<string[]>([])
const timeAgo = useGetTimeAgo()
const tick = useTickEveryMinute()
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@@ -70,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
/> />
) : undefined} ) : undefined}
<Text type="sm" style={[styles.ts, pal.textLight]}> <Text type="sm" style={[styles.ts, pal.textLight]}>
{ago(entry.timestamp)} {timeAgo(entry.timestamp, tick)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{expanded.includes(entry.id) ? ( {expanded.includes(entry.id) ? (
+560
View File
@@ -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<SVGIconProps>
} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
isWeb
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
style,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Icon
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{description}
</Text>
</View>
</View>
)
}
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 (
<View style={[]}>
<Button
label={_(msg`Load more`)}
onPress={item.onLoadMore}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.px_lg,
a.py_md,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View
style={[
a.relative,
{
height: 32,
width: 32 + 15 * items.length,
},
]}>
<View
style={[
a.align_center,
a.justify_center,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: 0,
borderWidth: 1,
backgroundColor: t.palette.primary_500,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: type === 'profile' ? 999 : 4,
zIndex: 4,
},
]}>
<ArrowBottom fill={t.palette.white} />
</View>
{items.map((_item, i) => {
return (
<View
key={_item.key}
style={[
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: (i + 1) * 15,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: _item.type === 'profile' ? 999 : 4,
zIndex: 3 - i,
},
]}>
{moderationOpts && (
<>
{_item.type === 'profile' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
moderation={_item.moderation.ui('avatar')}
/>
) : _item.type === 'feed' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
type="algo"
/>
) : null}
</>
)}
</View>
)
})}
</View>
<Text
style={[
a.pl_sm,
a.leading_snug,
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
]}>
{type === 'profile' ? (
<Trans>Load more suggested follows</Trans>
) : (
<Trans>Load more suggested feeds</Trans>
)}
</Text>
<View style={[a.flex_1, a.align_end]}>
{item.isLoadingMore && <Loader size="lg" />}
</View>
</View>
)}
</Button>
</View>
)
}
type ExploreScreenItems =
| {
type: 'header'
key: string
title: string
description: string
style?: ViewStyleProp['style']
icon: React.ComponentType<SVGIconProps>
}
| {
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<ExploreScreenItems[]>(() => {
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 (
<SuggestedItemsHeader
title={item.title}
description={item.description}
style={item.style}
icon={item.icon}
/>
)
}
case 'profile': {
return (
<View style={[a.border_b, t.atoms.border_contrast_low]}>
<ProfileCardWithFollowBtn profile={item.profile} noBg noBorder />
</View>
)
}
case 'feed': {
return (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default feed={item.feed} />
</View>
)
}
case 'loadMore': {
return <LoadMore item={item} moderationOpts={moderationOpts} />
}
case 'profilePlaceholder': {
return <ProfileCardFeedLoadingPlaceholder />
}
case 'feedPlaceholder': {
return <FeedFeedLoadingPlaceholder />
}
case 'error': {
return (
<View
style={[
a.border_t,
a.pt_md,
a.px_md,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.flex_row,
a.gap_md,
a.p_lg,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size="md" fill={t.palette.negative_400} />
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.font_bold, a.leading_snug]}>
{item.message}
</Text>
<Text
style={[
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{item.error}
</Text>
</View>
</View>
</View>
)
}
}
},
[t, moderationOpts],
)
return (
<List
data={items}
renderItem={renderItem}
keyExtractor={item => item.key}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
)
}
+58 -95
View File
@@ -29,15 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {augmentSearchQuery} from '#/lib/strings/helpers' import {augmentSearchQuery} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS, isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} 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 {List} from '#/view/com/util/List'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views' import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {Explore} from '#/view/screens/Search/Explore'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {atoms as a, useTheme as useThemeNew} from '#/alf'
import {atoms as a} from '#/alf' import * as FeedCard from '#/components/FeedCard'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
function Loader() { 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 ? (
<List
data={suggestions}
renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onEndReached={onEndReached}
onEndReachedThreshold={2}
/>
) : (
<CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
<ProfileCardFeedLoadingPlaceholder />
<ProfileCardFeedLoadingPlaceholder />
</CenteredView>
)
}
SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
type SearchResultSlice = type SearchResultSlice =
| { | {
type: 'post' type: 'post'
@@ -342,6 +278,52 @@ let SearchScreenUserResults = ({
} }
SearchScreenUserResults = React.memo(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 ? (
<List
data={results}
renderItem={({item}) => (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default feed={item} />
</View>
)}
keyExtractor={item => item.uri}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)
}
SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
@@ -389,6 +371,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
<SearchScreenUserResults query={query} active={activeTab === 2} /> <SearchScreenUserResults query={query} active={activeTab === 2} />
), ),
}, },
{
title: _(msg`Feeds`),
component: (
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
),
},
] ]
}, [_, query, activeTab]) }, [_, query, activeTab])
@@ -408,26 +396,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
))} ))}
</Pager> </Pager>
) : hasSession ? ( ) : hasSession ? (
<View> <Explore />
<CenteredView sideBorders style={pal.border}>
<Text
type="title"
style={[
pal.text,
pal.border,
{
display: 'flex',
paddingVertical: 12,
paddingHorizontal: 18,
fontWeight: 'bold',
},
]}>
<Trans>Suggested Follows</Trans>
</Text>
</CenteredView>
<SearchScreenSuggestedFollows />
</View>
) : ( ) : (
<CenteredView sideBorders style={pal.border}> <CenteredView sideBorders style={pal.border}>
<View <View
@@ -835,12 +804,6 @@ let SearchInputBox = ({
}) })
} else { } else {
setShowAutocomplete(true) setShowAutocomplete(true)
if (isIOS) {
// We rely on selectTextOnFocus, but it's broken on iOS:
// https://github.com/facebook/react-native/issues/41988
textInput.current?.setSelection(0, searchText.length)
// We still rely on selectTextOnFocus for it to be instant on Android.
}
} }
}} }}
onChangeText={onChangeText} onChangeText={onChangeText}
@@ -29,7 +29,8 @@ import {
useLoggedOutView, useLoggedOutView,
useLoggedOutViewControls, useLoggedOutViewControls,
} from '#/state/shell/logged-out' } from '#/state/shell/logged-out'
import {isWeb} from 'platform/detection' import {useGate} from 'lib/statsig/statsig'
import {isNative, isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated' import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding' import {Onboarding} from '#/screens/Onboarding'
import {SignupQueued} from '#/screens/SignupQueued' import {SignupQueued} from '#/screens/SignupQueued'
@@ -50,6 +51,7 @@ function NativeStackNavigator({
screenOptions, screenOptions,
...rest ...rest
}: NativeStackNavigatorProps) { }: NativeStackNavigatorProps) {
const gate = useGate()
// --- this is copy and pasted from the original native stack navigator --- // --- this is copy and pasted from the original native stack navigator ---
const {state, descriptors, navigation, NavigationContent} = const {state, descriptors, navigation, NavigationContent} =
useNavigationBuilder< useNavigationBuilder<
@@ -100,7 +102,11 @@ function NativeStackNavigator({
const {showLoggedOut} = useLoggedOutView() const {showLoggedOut} = useLoggedOutView()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const {isMobile, isTabletOrMobile} = useWebMediaQueries() const {isMobile, isTabletOrMobile} = useWebMediaQueries()
if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { const isNativePWIDisabled = isNative && gate('native_pwi_disabled')
if (
(!PWI_ENABLED || isNativePWIDisabled || activeRouteRequiresAuth) &&
!hasSession
) {
return <LoggedOut /> return <LoggedOut />
} }
if (hasSession && currentAccount?.signupQueued) { if (hasSession && currentAccount?.signupQueued) {
+5 -10
View File
@@ -34,10 +34,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.12.18": "@atproto/api@^0.12.20":
version "0.12.18" version "0.12.20"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.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" resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
zod@^3.14.2, zod@^3.20.2: zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4:
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:
version "3.23.8" version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==