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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+29
-22
@@ -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>
|
||||||
|
<MutedThreadsProvider>
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<TestCtrls />
|
<TestCtrls />
|
||||||
<Shell />
|
<Shell />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
@@ -147,7 +156,6 @@ function App() {
|
|||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<MutedThreadsProvider>
|
|
||||||
<InvitesStateProvider>
|
<InvitesStateProvider>
|
||||||
<ModalStateProvider>
|
<ModalStateProvider>
|
||||||
<DialogStateProvider>
|
<DialogStateProvider>
|
||||||
@@ -161,7 +169,6 @@ function App() {
|
|||||||
</DialogStateProvider>
|
</DialogStateProvider>
|
||||||
</ModalStateProvider>
|
</ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
</InvitesStateProvider>
|
||||||
</MutedThreadsProvider>
|
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
+20
-20
@@ -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>
|
||||||
|
<MutedThreadsProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
@@ -136,7 +138,6 @@ function App() {
|
|||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<MutedThreadsProvider>
|
|
||||||
<InvitesStateProvider>
|
<InvitesStateProvider>
|
||||||
<ModalStateProvider>
|
<ModalStateProvider>
|
||||||
<DialogStateProvider>
|
<DialogStateProvider>
|
||||||
@@ -150,7 +151,6 @@ function App() {
|
|||||||
</DialogStateProvider>
|
</DialogStateProvider>
|
||||||
</ModalStateProvider>
|
</ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
</InvitesStateProvider>
|
||||||
</MutedThreadsProvider>
|
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}>) {
|
}>) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
|
|||||||
)}
|
)}
|
||||||
confirmButtonCta={_(msg`Leave`)}
|
confirmButtonCta={_(msg`Leave`)}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
onConfirm={leaveConvo}
|
onConfirm={() => leaveConvo()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+97
@@ -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])
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,105 +1,138 @@
|
|||||||
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)
|
||||||
|
|
||||||
|
const onPressEdit = () => {
|
||||||
|
track('Post:EditThreadgateOpened')
|
||||||
|
if (isNative && Keyboard.isVisible()) {
|
||||||
|
Keyboard.dismiss()
|
||||||
|
}
|
||||||
|
openModal({
|
||||||
|
name: 'threadgate',
|
||||||
|
settings,
|
||||||
|
async onConfirm(newSettings: ThreadgateSetting[]) {
|
||||||
|
try {
|
||||||
|
if (newSettings.length) {
|
||||||
|
await createThreadgate(agent, post.uri, newSettings)
|
||||||
|
} else {
|
||||||
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
|
repo: agent.session!.did,
|
||||||
|
collection: 'app.bsky.feed.threadgate',
|
||||||
|
rkey: new AtUri(post.uri).rkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Toast.show('Thread settings updated')
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||||
|
})
|
||||||
|
track('Post:ThreadgateEdited')
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show(
|
||||||
|
'There was an issue. Please check your internet connection and try again.',
|
||||||
|
)
|
||||||
|
logger.error('Failed to edit threadgate', {message: err})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRootPost) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!settings.length && !isThreadAuthor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: isMobile ? 8 : 10,
|
gap: 10,
|
||||||
paddingHorizontal: isMobile ? 16 : 18,
|
paddingLeft: 18,
|
||||||
paddingVertical: 12,
|
paddingRight: 14,
|
||||||
borderWidth: 1,
|
paddingVertical: 10,
|
||||||
borderLeftWidth: isMobile ? 0 : 1,
|
borderTopWidth: 1,
|
||||||
borderRightWidth: isMobile ? 0 : 1,
|
|
||||||
},
|
},
|
||||||
|
pal.border,
|
||||||
containerStyles,
|
containerStyles,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
<View
|
<View style={{flex: 1, paddingVertical: 6}}>
|
||||||
style={[
|
|
||||||
{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 19,
|
|
||||||
},
|
|
||||||
iconStyles,
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'comments']}
|
|
||||||
size={16}
|
|
||||||
color={'#fff'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={{flex: 1}}>
|
|
||||||
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
|
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
|
||||||
{!record.allow?.length ? (
|
{!settings.length ? (
|
||||||
<Trans>Replies to this thread are disabled</Trans>
|
<Trans>Everybody can reply.</Trans>
|
||||||
|
) : settings[0].type === 'nobody' ? (
|
||||||
|
<Trans>Replies to this thread are disabled.</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
Only{' '}
|
Only{' '}
|
||||||
{record.allow.map((rule, i) => (
|
{settings.map((rule, i) => (
|
||||||
<>
|
<>
|
||||||
<Rule
|
<Rule
|
||||||
key={`rule-${i}`}
|
key={`rule-${i}`}
|
||||||
@@ -107,11 +140,7 @@ export function WhoCanReply({
|
|||||||
post={post}
|
post={post}
|
||||||
lists={post.threadgate!.lists}
|
lists={post.threadgate!.lists}
|
||||||
/>
|
/>
|
||||||
<Separator
|
<Separator key={`sep-${i}`} i={i} length={settings.length} />
|
||||||
key={`sep-${i}`}
|
|
||||||
i={i}
|
|
||||||
length={record.allow!.length}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
))}{' '}
|
))}{' '}
|
||||||
can reply.
|
can reply.
|
||||||
@@ -119,26 +148,41 @@ export function WhoCanReply({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Rule({
|
function Rule({
|
||||||
rule,
|
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)
|
||||||
|
|||||||
@@ -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,9 +60,6 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ProfileHoverCard inline did={opts.author.did}>
|
<ProfileHoverCard inline did={opts.author.did}>
|
||||||
<View
|
|
||||||
onPointerMove={isWeb ? onPointerMove : undefined}
|
|
||||||
style={[a.flex_1]}>
|
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
|
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
|
||||||
@@ -103,7 +89,6 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||||||
anchorNoUnderline
|
anchorNoUnderline
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
</ProfileHoverCard>
|
</ProfileHoverCard>
|
||||||
{!isAndroid && (
|
{!isAndroid && (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
logger.error('Failed to toggle thread mute', {message: e})
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+13
-47
@@ -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,25 +322,11 @@ 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 => {
|
|
||||||
if (
|
|
||||||
!hasSession &&
|
|
||||||
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const alreadySaved = Boolean(
|
|
||||||
preferences?.savedFeeds?.find(f => {
|
|
||||||
return f.value === feed.uri
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return !alreadySaved
|
|
||||||
})
|
|
||||||
.map(feed => ({
|
|
||||||
key: `popularFeed:${feed.uri}`,
|
key: `popularFeed:${feed.uri}`,
|
||||||
type: 'popularFeed',
|
type: 'popularFeed',
|
||||||
feedUri: feed.uri,
|
feedUri: feed.uri,
|
||||||
|
feed,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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) ? (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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==
|
||||||
|
|||||||
Reference in New Issue
Block a user