Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a2762c237 | |||
| 6723e43d0c | |||
| e39efa92c2 | |||
| 9306c0277a | |||
| c3f5bfd7aa | |||
| 55dd945e7f | |||
| 8314f3d00e | |||
| 4966de9777 | |||
| f46049dac9 | |||
| 17042f523c | |||
| 84bdba9974 | |||
| 04c101dd80 | |||
| 49a162c38d | |||
| 17c03b4ed7 | |||
| ee90f32707 | |||
| ae775295ca | |||
| e2f06e2bd2 | |||
| 773ad8f03d | |||
| 2d3b90be03 | |||
| bbff994618 | |||
| 725520a0f2 | |||
| 16003631a5 |
@@ -4,5 +4,3 @@ SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_ENV=development
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: build-and-push-bskyweb-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 3p-moderators
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
@@ -54,6 +54,3 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
|
||||
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
|
||||
|
||||
@@ -57,8 +57,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -65,8 +65,6 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -144,8 +144,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
@@ -224,8 +222,6 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
@@ -287,8 +283,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.81.0",
|
||||
"version": "1.80.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
+39
-51
@@ -4,6 +4,7 @@ import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
@@ -15,7 +16,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
@@ -34,7 +34,6 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
@@ -54,9 +53,8 @@ import {listenSessionDropped} from './state/events'
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -64,61 +62,49 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [initSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
}, [_])
|
||||
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
</ThemeProvider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
</Alf>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
@@ -156,7 +142,9 @@ function App() {
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
<KeyboardProvider>
|
||||
<InnerApp />
|
||||
</KeyboardProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
|
||||
+29
-53
@@ -4,11 +4,8 @@ import 'view/icons'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
@@ -25,85 +22,64 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [initSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
}, [_])
|
||||
}, [resumeSession])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady) return null
|
||||
if (isInitialLoad) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-6
@@ -48,7 +48,7 @@ import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesScreen} from './screens/Messages/List'
|
||||
import {MessagesListScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
@@ -462,8 +462,8 @@ function MessagesTabNavigator() {
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
|
||||
options={{title: title(msg`Notifications`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
@@ -570,7 +570,7 @@ const LINKING = {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
if (name === 'Messages') {
|
||||
return buildStateObject('MessagesTab', 'Messages', params)
|
||||
return buildStateObject('MessagesTab', 'MessagesList', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
|
||||
@@ -16,14 +16,12 @@ export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
pendingDid,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
pendingDid: string | null
|
||||
}) {
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -33,7 +31,6 @@ export function AccountList({
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents={pendingDid ? 'none' : 'auto'}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
@@ -46,7 +43,6 @@ export function AccountList({
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
@@ -54,7 +50,7 @@ export function AccountList({
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
@@ -63,7 +59,8 @@ export function AccountList({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -87,12 +84,10 @@ function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
isPendingAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
isPendingAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -120,7 +115,7 @@ function AccountItem({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
|
||||
@@ -43,9 +43,7 @@ export function Outer({
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
{children}
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
|
||||
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner />
|
||||
<MutedWordsInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner() {
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
@@ -54,7 +54,6 @@ export function SwitchAccountDialog({
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
@@ -16,7 +16,7 @@ import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
export const ActionsWrapper = function GrowWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
@@ -35,13 +35,6 @@ export function ActionsWrapper({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
|
||||
const shrink = useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
@@ -56,11 +49,11 @@ export function ActionsWrapper({
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
runOnJS(menuControl.open)()
|
||||
|
||||
shrink()
|
||||
})
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, open])
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, menuControl])
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {NativeSyntheticEvent, StyleSheet, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -30,14 +30,13 @@ export function ActionsWrapper({
|
||||
|
||||
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
|
||||
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
|
||||
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
|
||||
const onFocus = React.useCallback((e: NativeSyntheticEvent<any>) => {
|
||||
if (e.nativeEvent.relatedTarget == null) return
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
@@ -47,7 +46,7 @@ export function ActionsWrapper({
|
||||
{isFromSelf && (
|
||||
<View
|
||||
style={[
|
||||
a.mr_xl,
|
||||
a.mr_md,
|
||||
a.justify_center,
|
||||
{
|
||||
marginLeft: 'auto',
|
||||
@@ -58,8 +57,6 @@ export function ActionsWrapper({
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -76,8 +73,6 @@ export function ActionsWrapper({
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable} from 'react-native'
|
||||
import {Pressable} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -72,7 +72,7 @@ let ConvoMenu = ({
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace('Messages')
|
||||
navigation.replace('MessagesList')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -88,11 +88,6 @@ let ConvoMenu = ({
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
|
||||
props.onPress()
|
||||
}}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
@@ -128,7 +123,6 @@ let ConvoMenu = ({
|
||||
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{/* TODO(samuel): implement these */}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
@@ -152,7 +146,6 @@ let ConvoMenu = ({
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Leave conversation`)}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {StyleProp, TextStyle} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageItemMetadata({
|
||||
message,
|
||||
isLastInGroup,
|
||||
style,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isLastInGroup: boolean
|
||||
style: StyleProp<TextStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
}).format(date)
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 1 minute
|
||||
if (diff < 1000 * 60) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
// if in the last day
|
||||
if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
|
||||
return time
|
||||
}
|
||||
|
||||
// if yesterday
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (
|
||||
yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
|
||||
) {
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
if (!isLastInGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
a.mt_xs,
|
||||
a.mb_lg,
|
||||
style,
|
||||
]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
|
||||
import React, {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {MessageItemMetadata} from '#/components/dms/MesageItemMetadata'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export let MessageItem = ({
|
||||
export function MessageItem({
|
||||
item,
|
||||
next,
|
||||
pending,
|
||||
}: {
|
||||
item: ChatBskyConvoDefs.MessageView
|
||||
next:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
pending?: boolean
|
||||
}): React.ReactNode => {
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -32,54 +28,37 @@ export let MessageItem = ({
|
||||
next.sender?.did === currentAccount?.did
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
|
||||
if (item.id.length > 13) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the next message is from a different sender, then it's the last in the group
|
||||
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
|
||||
return true
|
||||
}
|
||||
|
||||
// or, if there's a 3 minute gap between this message and the next
|
||||
// or, if there's a 10 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(next)) {
|
||||
const thisDate = new Date(item.sentAt)
|
||||
const nextDate = new Date(next.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
// 3 minutes
|
||||
return diff > 3 * 60 * 1000
|
||||
// 10 minutes
|
||||
return diff > 10 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [item, next, isFromSelf, isNextFromSelf])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
lastInGroupRef.current = isLastInGroup
|
||||
}
|
||||
|
||||
const pendingColor =
|
||||
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.px_lg,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? pending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
@@ -92,7 +71,6 @@ export let MessageItem = ({
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
pending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
@@ -106,93 +84,3 @@ export let MessageItem = ({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItem = React.memo(MessageItem)
|
||||
|
||||
let MessageItemMetadata = ({
|
||||
message,
|
||||
isLastInGroup,
|
||||
style,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isLastInGroup: boolean
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
}).format(date)
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 1 minute
|
||||
if (diff < 1000 * 60) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
// if in the last day
|
||||
if (localDateString(now) === localDateString(date)) {
|
||||
return time
|
||||
}
|
||||
|
||||
// if yesterday
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (localDateString(yesterday) === localDateString(date)) {
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
if (!isLastInGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
style,
|
||||
]}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItemMetadata = React.memo(MessageItemMetadata)
|
||||
|
||||
function localDateString(date: Date) {
|
||||
// can't use toISOString because it should be in local time
|
||||
const mm = date.getMonth()
|
||||
const dd = date.getDate()
|
||||
const yyyy = date.getFullYear()
|
||||
// not padding with 0s because it's not necessary, it's just used for comparison
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useChat} from 'state/messages'
|
||||
import {ConvoStatus} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
@@ -16,7 +12,6 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
||||
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
@@ -33,29 +28,13 @@ export let MessageMenu = ({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const chat = useChat()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
// use when we have rich text
|
||||
// const str = richTextToString(richText, true)
|
||||
|
||||
Clipboard.setStringAsync(message.text)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, message.text])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
if (chat.status !== ConvoStatus.Ready) return
|
||||
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
chat
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, chat, message.id, retryDeleteControl])
|
||||
// TODO delete the message
|
||||
}, [])
|
||||
|
||||
const onReport = React.useCallback(() => {
|
||||
// TODO report the message
|
||||
@@ -74,6 +53,8 @@ export let MessageMenu = ({
|
||||
a.p_sm,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
// make sure pfp is in the middle
|
||||
{marginLeft: -10},
|
||||
]}>
|
||||
<DotsHorizontal size="sm" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
@@ -83,22 +64,12 @@ export let MessageMenu = ({
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
label={_(msg`Delete message`)}
|
||||
onPress={deleteControl.open}>
|
||||
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
{!isFromSelf && (
|
||||
@@ -124,17 +95,6 @@ export let MessageMenu = ({
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={retryDeleteControl}
|
||||
title={_(msg`Failed to delete message`)}
|
||||
description={_(
|
||||
msg`An error occurred while trying to delete the message. Please try again.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Button} from '../Button'
|
||||
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
|
||||
import {ListMaybePlaceholder} from '../Lists'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
@@ -179,7 +178,7 @@ function SearchablePeopleList({
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<Dialog.Input
|
||||
<TextField.Input
|
||||
label={_(msg`Search profiles`)}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
@@ -198,7 +197,6 @@ function SearchablePeopleList({
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
@@ -213,35 +211,20 @@ function SearchablePeopleList({
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{searchText.length === 0 ? (
|
||||
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
|
||||
<Envelope width={64} fill={t.palette.contrast_200} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
a.mt_md,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Search for someone to start a conversation with.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
!actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
{searchText.length > 0 && !actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
|
||||
const firstTimeRef = useRef(true)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (firstTimeRef.current) {
|
||||
firstTimeRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
refetch()
|
||||
}, [refetch]),
|
||||
)
|
||||
}
|
||||
+3
-13
@@ -4,17 +4,7 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
|
||||
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
|
||||
// for Statsig reporting and shouldn't be used to identify a specific bundle.
|
||||
export const BUNDLE_DATE =
|
||||
IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
|
||||
IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
|
||||
IS_DEV ? 'development' : UPDATES_CHANNEL
|
||||
})`
|
||||
|
||||
@@ -1,17 +1,2 @@
|
||||
import {version} from '../../package.json'
|
||||
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
|
||||
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
|
||||
// for Statsig reporting and shouldn't be used to identify a specific bundle.
|
||||
export const BUNDLE_DATE = IS_DEV
|
||||
? 0
|
||||
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = version
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -8,14 +8,12 @@ import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {initSession} = useSessionApi()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
@@ -24,12 +22,8 @@ export function useAccountSwitcher() {
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
if (account.accessJwt) {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
@@ -39,9 +33,10 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
await selectAccount(account, logContext)
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} else {
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
Toast.show(
|
||||
@@ -53,12 +48,14 @@ export function useAccountSwitcher() {
|
||||
logger.error(`switch account: selectAccount failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
clearCurrentAccount() // back user out to login
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Sorry! We need you to enter your password.`))
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
[_, track, initSession, requestSwitchToAccount, pendingDid],
|
||||
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
return {onPressSwitchAccount}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ export function useNavigationTabState() {
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
|
||||
}
|
||||
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtSearch &&
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
|
||||
export function useNavigationTabState() {
|
||||
@@ -10,7 +9,6 @@ export function useNavigationTabState() {
|
||||
isAtSearch: currentRoute === 'Search',
|
||||
isAtNotifications: currentRoute === 'Notifications',
|
||||
isAtMyProfile: currentRoute === 'MyProfile',
|
||||
isAtMessages: currentRoute === 'Messages',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesTab: undefined
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow_v2'
|
||||
| 'disable_min_shell_on_foregrounding_v3'
|
||||
| 'disable_min_shell_on_foregrounding_v2'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'dms'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'start_session_with_following_v2'
|
||||
| 'test_gate_1'
|
||||
|
||||
@@ -9,5 +9,4 @@ export const DebugContext = {
|
||||
// e.g. composer: 'composer'
|
||||
session: 'session',
|
||||
notifications: 'notifications',
|
||||
convo: 'convo',
|
||||
} as const
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const router = new Router({
|
||||
CommunityGuidelines: '/support/community-guidelines',
|
||||
CopyrightPolicy: '/support/copyright',
|
||||
Hashtag: '/hashtag/:tag',
|
||||
Messages: '/messages',
|
||||
MessagesList: '/messages',
|
||||
MessagesSettings: '/messages/settings',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ export const ChooseAccountForm = ({
|
||||
onSelectAccount: (account?: SessionAccount) => void
|
||||
onPressBack: () => void
|
||||
}) => {
|
||||
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -35,48 +34,33 @@ export const ChooseAccountForm = ({
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
if (!account.accessJwt) {
|
||||
// Move to login form.
|
||||
if (account.accessJwt) {
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
} else {
|
||||
try {
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
onSelectAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSelectAccount(account)
|
||||
return
|
||||
}
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
// Move to login form.
|
||||
onSelectAccount(account)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
currentAccount,
|
||||
track,
|
||||
initSession,
|
||||
pendingDid,
|
||||
onSelectAccount,
|
||||
setShowLoggedOut,
|
||||
_,
|
||||
],
|
||||
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -90,7 +74,6 @@ export const ChooseAccountForm = ({
|
||||
<AccountList
|
||||
onSelectAccount={onSelect}
|
||||
onSelectOther={() => onSelectAccount()}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_row]}>
|
||||
|
||||
@@ -12,21 +12,20 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
scrollToEnd,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
scrollToEnd: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const [message, setMessage] = React.useState('')
|
||||
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
|
||||
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
|
||||
@@ -40,12 +39,11 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
onSendMessage(message.trimEnd())
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}, [message, onSendMessage, playHaptic])
|
||||
}, [message, onSendMessage])
|
||||
|
||||
const onInputLayout = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
||||
@@ -57,55 +55,49 @@ export function MessageInput({
|
||||
|
||||
setMaxHeight(max)
|
||||
setIsInputScrollable(availableSpace < 30)
|
||||
|
||||
scrollToEnd()
|
||||
},
|
||||
[scrollToEnd, topInset],
|
||||
[topInset],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
a.mt_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextInput
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
multiline={true}
|
||||
onChangeText={setMessage}
|
||||
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
scrollEnabled={isInputScrollable}
|
||||
blurOnSubmit={false}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onContentSizeChange={onInputLayout}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextInput
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
multiline={true}
|
||||
onChangeText={setMessage}
|
||||
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
scrollEnabled={isInputScrollable}
|
||||
blurOnSubmit={false}
|
||||
onFocus={scrollToEnd}
|
||||
onContentSizeChange={onInputLayout}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export function MessageInput({
|
||||
onSendMessage,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
scrollToEnd: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -44,51 +45,47 @@ export function MessageInput({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
a.mt_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextareaAutosize
|
||||
style={StyleSheet.flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
paddingTop: 6,
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}>
|
||||
<TextareaAutosize
|
||||
style={StyleSheet.flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
paddingTop: 4,
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<PaperPlane fill={t.palette.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageListError({
|
||||
item,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'error-recoverable'}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const message = React.useMemo(() => {
|
||||
return {
|
||||
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
|
||||
[ConvoItemError.ResumeFailed]: _(
|
||||
msg`There was an issue connecting to the chat.`,
|
||||
),
|
||||
[ConvoItemError.PollFailed]: _(
|
||||
msg`This chat was disconnected due to a network error.`,
|
||||
),
|
||||
}[item.code]
|
||||
}, [_, item.code])
|
||||
|
||||
return (
|
||||
<View style={[a.py_md, a.align_center]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.pt_md,
|
||||
a.pb_lg,
|
||||
a.px_3xl,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
<CircleInfo size="lg" fill={t.palette.negative_400} />
|
||||
<Text style={[a.pt_sm, a.leading_snug]}>
|
||||
{message}{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label={_(msg`Press to retry`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
item.retry()
|
||||
return false
|
||||
}}>
|
||||
{_(msg`Retry.`)}
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import React, {useCallback, useRef} from 'react'
|
||||
import {FlatList, View} from 'react-native'
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
FlatList,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useChat} from '#/state/messages'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
|
||||
import {ScrollProvider} from 'lib/ScrollContext'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {Loader} from '#/components/Loader'
|
||||
@@ -59,19 +53,11 @@ function RetryButton({onPress}: {onPress: () => unknown}) {
|
||||
|
||||
function renderItem({item}: {item: ConvoItem}) {
|
||||
if (item.type === 'message' || item.type === 'pending-message') {
|
||||
return (
|
||||
<MessageItem
|
||||
item={item.message}
|
||||
next={item.nextMessage}
|
||||
pending={item.type === 'pending-message'}
|
||||
/>
|
||||
)
|
||||
return <MessageItem item={item.message} next={item.nextMessage} />
|
||||
} else if (item.type === 'deleted-message') {
|
||||
return <Text>Deleted message</Text>
|
||||
} else if (item.type === 'pending-retry') {
|
||||
return <RetryButton onPress={item.retry} />
|
||||
} else if (item.type === 'error-recoverable') {
|
||||
return <MessageListError item={item} />
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -81,178 +67,100 @@ function keyExtractor(item: ConvoItem) {
|
||||
return item.key
|
||||
}
|
||||
|
||||
function onScrollToIndexFailed() {
|
||||
function onScrollToEndFailed() {
|
||||
// Placeholder function. You have to give FlatList something or else it will error.
|
||||
}
|
||||
|
||||
export function MessagesList() {
|
||||
const chat = useChat()
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
// We use this to know if we should scroll after a new clop is added to the list
|
||||
const isAtBottom = useRef(false)
|
||||
const currentOffset = React.useRef(0)
|
||||
|
||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||
// the bottom.
|
||||
const isAtBottom = useSharedValue(true)
|
||||
|
||||
// This will be used on web to assist in determing if we need to maintain the content offset
|
||||
const isAtTop = useSharedValue(true)
|
||||
|
||||
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
|
||||
// onStartReached to fire.
|
||||
const contentHeight = useSharedValue(0)
|
||||
|
||||
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
|
||||
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
|
||||
const isMomentumScrolling = useSharedValue(false)
|
||||
|
||||
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
|
||||
|
||||
// Every time the content size changes, that means one of two things is happening:
|
||||
// 1. New messages are being added from the log or from a message you have sent
|
||||
// 2. Old messages are being prepended to the top
|
||||
//
|
||||
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
|
||||
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
|
||||
//
|
||||
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
|
||||
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
|
||||
// we will not scroll whenever new items get prepended to the top.
|
||||
const onContentSizeChange = useCallback(
|
||||
(_: number, height: number) => {
|
||||
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
|
||||
// previous offset whenever we add new content to the previous offset whenever we add new content to the list.
|
||||
if (isWeb && isAtTop.value && hasInitiallyScrolled) {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: height - contentHeight.value,
|
||||
})
|
||||
}
|
||||
|
||||
contentHeight.value = height
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component
|
||||
if (height <= 50 || !isAtBottom.value) {
|
||||
return
|
||||
}
|
||||
|
||||
flatListRef.current?.scrollToOffset({
|
||||
animated: hasInitiallyScrolled,
|
||||
offset: height,
|
||||
})
|
||||
isMomentumScrolling.value = true
|
||||
},
|
||||
[
|
||||
contentHeight,
|
||||
hasInitiallyScrolled,
|
||||
isAtBottom.value,
|
||||
isAtTop.value,
|
||||
isMomentumScrolling,
|
||||
],
|
||||
)
|
||||
|
||||
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
|
||||
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
|
||||
const onStartReached = useCallback(() => {
|
||||
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
|
||||
chat.fetchMessageHistory()
|
||||
const onContentSizeChange = useCallback(() => {
|
||||
if (currentOffset.current <= 100) {
|
||||
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
||||
}
|
||||
}, [chat, hasInitiallyScrolled])
|
||||
}, [])
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
chat.service.fetchMessageHistory()
|
||||
}, [chat])
|
||||
|
||||
const onInputFocus = useCallback(() => {
|
||||
if (!isAtBottom.current) {
|
||||
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onInputBlur = useCallback(() => {}, [])
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
if (chat.status === ConvoStatus.Ready) {
|
||||
chat.sendMessage({
|
||||
text,
|
||||
})
|
||||
}
|
||||
chat.service.sendMessage({
|
||||
text,
|
||||
})
|
||||
},
|
||||
[chat],
|
||||
[chat.service],
|
||||
)
|
||||
|
||||
const onScroll = React.useCallback(
|
||||
(e: ReanimatedScrollEvent) => {
|
||||
'worklet'
|
||||
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
||||
|
||||
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
|
||||
// when a new message is added, hence the 100 pixel offset
|
||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
||||
isAtTop.value = e.contentOffset.y <= 1
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component.
|
||||
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
|
||||
// adds a 50 pixel offset.
|
||||
if (contentHeight.value > 50 && !hasInitiallyScrolled) {
|
||||
runOnJS(setHasInitiallyScrolled)(true)
|
||||
}
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
currentOffset.current = e.nativeEvent.contentOffset.y
|
||||
},
|
||||
[contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop],
|
||||
[],
|
||||
)
|
||||
|
||||
const onMomentumEnd = React.useCallback(() => {
|
||||
'worklet'
|
||||
isMomentumScrolling.value = false
|
||||
}, [isMomentumScrolling])
|
||||
|
||||
const scrollToEnd = React.useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (isMomentumScrolling.value) return
|
||||
|
||||
flatListRef.current?.scrollToEnd({animated: true})
|
||||
isMomentumScrolling.value = true
|
||||
})
|
||||
}, [isMomentumScrolling])
|
||||
|
||||
const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
|
||||
|
||||
// This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly.
|
||||
const scrollToEndNow = React.useCallback(() => {
|
||||
flatListRef.current?.scrollToEnd({animated: false})
|
||||
}, [])
|
||||
|
||||
useKeyboardHandler({
|
||||
onMove: () => {
|
||||
'worklet'
|
||||
runOnJS(scrollToEndNow)()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
|
||||
keyboardVerticalOffset={isIOS ? topInset : 0}
|
||||
style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
|
||||
behavior="padding"
|
||||
contentContainerStyle={a.flex_1}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={chat.items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableVirtualization={true}
|
||||
initialNumToRender={isWeb ? 50 : 25}
|
||||
maxToRenderPerBatch={isWeb ? 50 : 25}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 1,
|
||||
}}
|
||||
containWeb={true}
|
||||
contentContainerStyle={{paddingHorizontal: 10}}
|
||||
removeClippedSubviews={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<MaybeLoader isLoading={chat.isFetchingHistory} />
|
||||
}
|
||||
keyboardVerticalOffset={70}
|
||||
contentContainerStyle={{flex: 1}}>
|
||||
<FlatList
|
||||
data={
|
||||
chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
|
||||
}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{paddingHorizontal: 10}}
|
||||
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only
|
||||
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower
|
||||
// this...probably.
|
||||
initialNumToRender={20}
|
||||
// Same with the max to render per batch. Let's be safe for now though.
|
||||
maxToRenderPerBatch={25}
|
||||
inverted={true}
|
||||
onEndReached={onEndReached}
|
||||
onScrollToIndexFailed={onScrollToEndFailed}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onScroll={onScroll}
|
||||
// We don't really need to call this much since there are not any animations that rely on this
|
||||
scrollEventThrottle={100}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 1,
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<MaybeLoader
|
||||
isLoading={
|
||||
chat.state.status === ConvoStatus.Ready &&
|
||||
chat.state.isFetchingHistory
|
||||
}
|
||||
/>
|
||||
}
|
||||
removeClippedSubviews={true}
|
||||
ref={flatListRef}
|
||||
keyboardDismissMode="none"
|
||||
/>
|
||||
|
||||
<View style={{paddingHorizontal: 10}}>
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
@@ -14,6 +14,7 @@ import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ChatProvider, useChat} from 'state/messages'
|
||||
import {ConvoStatus} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
|
||||
@@ -42,30 +43,29 @@ export function MessagesConversationScreen({route}: Props) {
|
||||
|
||||
function Inner() {
|
||||
const chat = useChat()
|
||||
const {currentAccount} = useSession()
|
||||
const myDid = currentAccount?.did
|
||||
|
||||
if (
|
||||
chat.status === ConvoStatus.Uninitialized ||
|
||||
chat.status === ConvoStatus.Initializing
|
||||
) {
|
||||
return <ListMaybePlaceholder isLoading />
|
||||
const otherProfile = React.useMemo(() => {
|
||||
if (chat.state.status !== ConvoStatus.Ready) return
|
||||
return chat.state.convo.members.find(m => m.did !== myDid)
|
||||
}, [chat.state, myDid])
|
||||
|
||||
// TODO whenever we have error messages, we should use them in here -hailey
|
||||
if (chat.state.status !== ConvoStatus.Ready || !otherProfile) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={chat.state.status === ConvoStatus.Error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (chat.status === ConvoStatus.Error) {
|
||||
// TODO error
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* Any other chat states (atm) are "ready" states
|
||||
*/
|
||||
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Header profile={chat.recipients[0]} />
|
||||
<MessagesList />
|
||||
</CenteredView>
|
||||
</KeyboardProvider>
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Header profile={otherProfile} />
|
||||
<MessagesList />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,19 +78,22 @@ let Header = ({
|
||||
const {_} = useLingui()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const chat = useChat()
|
||||
const {service} = useChat()
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('Messages')
|
||||
navigation.replace('MessagesList')
|
||||
} else {
|
||||
navigation.pop()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onUpdateConvo = useCallback(() => {
|
||||
// TODO eric update muted state
|
||||
}, [])
|
||||
const onUpdateConvo = useCallback(
|
||||
(convo: ChatBskyConvoDefs.ConvoView) => {
|
||||
service.convo = convo
|
||||
},
|
||||
[service],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -126,15 +129,15 @@ let Header = ({
|
||||
) : (
|
||||
<View style={{width: 30}} />
|
||||
)}
|
||||
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
|
||||
<View style={[a.align_center, a.gap_sm]}>
|
||||
<PreviewableUserAvatar size={32} profile={profile} />
|
||||
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
|
||||
{profile.displayName}
|
||||
<Text style={[a.text_lg, a.font_bold]}>
|
||||
<Trans>{profile.displayName}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{chat.status === ConvoStatus.Ready ? (
|
||||
{service.convo ? (
|
||||
<ConvoMenu
|
||||
convo={chat.convo}
|
||||
convo={service.convo}
|
||||
profile={profile}
|
||||
onUpdateConvo={onUpdateConvo}
|
||||
currentScreen="conversation"
|
||||
|
||||
@@ -6,7 +6,6 @@ import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {sha256} from 'js-sha256'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {MessagesTabNavigatorParams} from '#/lib/routes/types'
|
||||
@@ -21,14 +20,11 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {NewChat} from '#/components/dms/NewChat'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
||||
import {Link} from '#/components/Link'
|
||||
@@ -36,25 +32,14 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ClipClopGate} from '../gate'
|
||||
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
|
||||
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
||||
export function MessagesScreen({navigation}: Props) {
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
|
||||
export function MessagesListScreen({navigation}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const newChatControl = useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
// TEMP
|
||||
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
|
||||
const hasValidServiceUrl = useMemo(() => {
|
||||
const hash = sha256(serviceUrl)
|
||||
return (
|
||||
hash ===
|
||||
'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f'
|
||||
)
|
||||
}, [serviceUrl])
|
||||
|
||||
const renderButton = useCallback(() => {
|
||||
return (
|
||||
<Link
|
||||
@@ -77,9 +62,7 @@ export function MessagesScreen({navigation}: Props) {
|
||||
fetchNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useListConvos({refetchInterval: 15_000})
|
||||
|
||||
useRefreshOnFocus(refetch)
|
||||
} = useListConvos()
|
||||
|
||||
const isError = !!error
|
||||
|
||||
@@ -129,25 +112,6 @@ export function MessagesScreen({navigation}: Props) {
|
||||
const gate = useGate()
|
||||
if (!gate('dms')) return <ClipClopGate />
|
||||
|
||||
if (!hasValidServiceUrl) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={a.p_lg}>
|
||||
<View>
|
||||
<TextField.LabelText>Service URL</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
value={serviceUrl}
|
||||
onChangeText={text => setServiceUrl(text)}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
label="https://"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
if (conversations.length < 1) {
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
@@ -273,9 +237,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
<PreviewableUserAvatar profile={otherUser} size={42} />
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
|
||||
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
|
||||
<Text
|
||||
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
|
||||
{otherUser.displayName || otherUser.handle}
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import {describe, it} from '@jest/globals'
|
||||
|
||||
describe(`#/state/messages/convo`, () => {
|
||||
describe(`init`, () => {
|
||||
it.todo(`fails if sender and recipients aren't found`)
|
||||
describe(`status states`, () => {
|
||||
it.todo(`cannot re-initialize from a non-unintialized state`)
|
||||
it.todo(`can re-initialize from a failed state`)
|
||||
})
|
||||
|
||||
describe(`resume`, () => {
|
||||
it.todo(`restores previous state if resume fails`)
|
||||
})
|
||||
|
||||
describe(`suspend`, () => {
|
||||
it.todo(`cannot be interacted with when suspended`)
|
||||
it.todo(`polling is stopped when suspended`)
|
||||
})
|
||||
|
||||
describe(`read states`, () => {
|
||||
it.todo(`should mark messages as read as they come in`)
|
||||
describe(`destroy`, () => {
|
||||
it.todo(`cannot be interacted with when destroyed`)
|
||||
it.todo(`polling is stopped when destroyed`)
|
||||
it.todo(`events are cleaned up when destroyed`)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`history fetching`, () => {
|
||||
|
||||
+244
-583
@@ -4,9 +4,9 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyConvoSendMessage,
|
||||
} from '@atproto-labs/api'
|
||||
import {EventEmitter} from 'eventemitter3'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export type ConvoParams = {
|
||||
@@ -18,21 +18,9 @@ export type ConvoParams = {
|
||||
export enum ConvoStatus {
|
||||
Uninitialized = 'uninitialized',
|
||||
Initializing = 'initializing',
|
||||
Resuming = 'resuming',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Backgrounded = 'backgrounded',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum ConvoItemError {
|
||||
HistoryFailed = 'historyFailed',
|
||||
ResumeFailed = 'resumeFailed',
|
||||
PollFailed = 'pollFailed',
|
||||
}
|
||||
|
||||
export enum ConvoError {
|
||||
InitFailed = 'initFailed',
|
||||
Destroyed = 'destroyed',
|
||||
}
|
||||
|
||||
export type ConvoItem =
|
||||
@@ -59,109 +47,27 @@ export type ConvoItem =
|
||||
key: string
|
||||
retry: () => void
|
||||
}
|
||||
| {
|
||||
type: 'error-recoverable'
|
||||
key: string
|
||||
code: ConvoItemError
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export type ConvoState =
|
||||
| {
|
||||
status: ConvoStatus.Uninitialized
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Initializing
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Ready
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => void
|
||||
fetchMessageHistory: () => void
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Suspended
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Backgrounded
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Resuming
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Error
|
||||
items: []
|
||||
convo: undefined
|
||||
error: any
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
|
||||
const ACTIVE_POLL_INTERVAL = 2e3
|
||||
const BACKGROUND_POLL_INTERVAL = 10e3
|
||||
| {
|
||||
status: ConvoStatus.Destroyed
|
||||
}
|
||||
|
||||
export function isConvoItemMessage(
|
||||
item: ConvoItem,
|
||||
@@ -178,19 +84,15 @@ export class Convo {
|
||||
private agent: BskyAgent
|
||||
private __tempFromUserDid: string
|
||||
|
||||
private pollInterval = ACTIVE_POLL_INTERVAL
|
||||
private status: ConvoStatus = ConvoStatus.Uninitialized
|
||||
private error:
|
||||
| {
|
||||
code: ConvoError
|
||||
exception?: Error
|
||||
retry: () => void
|
||||
}
|
||||
| undefined
|
||||
private error: any
|
||||
private historyCursor: string | undefined | null = undefined
|
||||
private isFetchingHistory = false
|
||||
private eventsCursor: string | undefined = undefined
|
||||
private pollingFailure = false
|
||||
|
||||
convoId: string
|
||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
|
||||
private pastMessages: Map<
|
||||
string,
|
||||
@@ -204,214 +106,87 @@ export class Convo {
|
||||
string,
|
||||
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
|
||||
> = new Map()
|
||||
private deletedMessages: Set<string> = new Set()
|
||||
private footerItems: Map<string, ConvoItem> = new Map()
|
||||
private headerItems: Map<string, ConvoItem> = new Map()
|
||||
|
||||
private pendingEventIngestion: Promise<void> | undefined
|
||||
private isProcessingPendingMessages = false
|
||||
|
||||
convoId: string
|
||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined
|
||||
snapshot: ConvoState | undefined
|
||||
|
||||
constructor(params: ConvoParams) {
|
||||
this.convoId = params.convoId
|
||||
this.agent = params.agent
|
||||
this.__tempFromUserDid = params.__tempFromUserDid
|
||||
|
||||
this.subscribe = this.subscribe.bind(this)
|
||||
this.getSnapshot = this.getSnapshot.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.deleteMessage = this.deleteMessage.bind(this)
|
||||
this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
|
||||
}
|
||||
|
||||
private commit() {
|
||||
this.snapshot = undefined
|
||||
this.subscribers.forEach(subscriber => subscriber())
|
||||
}
|
||||
async initialize() {
|
||||
if (this.status !== 'uninitialized') return
|
||||
this.status = ConvoStatus.Initializing
|
||||
|
||||
private subscribers: (() => void)[] = []
|
||||
|
||||
subscribe(subscriber: () => void) {
|
||||
if (this.subscribers.length === 0) this.init()
|
||||
|
||||
this.subscribers.push(subscriber)
|
||||
|
||||
return () => {
|
||||
this.subscribers = this.subscribers.filter(s => s !== subscriber)
|
||||
if (this.subscribers.length === 0) this.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): ConvoState {
|
||||
if (!this.snapshot) this.snapshot = this.generateSnapshot()
|
||||
// logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
private generateSnapshot(): ConvoState {
|
||||
switch (this.status) {
|
||||
case ConvoStatus.Initializing: {
|
||||
return {
|
||||
status: ConvoStatus.Initializing,
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: undefined,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: this.isFetchingHistory,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
fetchMessageHistory: undefined,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Suspended:
|
||||
case ConvoStatus.Backgrounded:
|
||||
case ConvoStatus.Resuming:
|
||||
case ConvoStatus.Ready: {
|
||||
return {
|
||||
status: this.status,
|
||||
items: this.getItems(),
|
||||
convo: this.convo!,
|
||||
error: undefined,
|
||||
sender: this.sender!,
|
||||
recipients: this.recipients!,
|
||||
isFetchingHistory: this.isFetchingHistory,
|
||||
deleteMessage: this.deleteMessage,
|
||||
sendMessage: this.sendMessage,
|
||||
fetchMessageHistory: this.fetchMessageHistory,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Error: {
|
||||
return {
|
||||
status: ConvoStatus.Error,
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: this.error,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: false,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
fetchMessageHistory: undefined,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: ConvoStatus.Uninitialized,
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: undefined,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: false,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
fetchMessageHistory: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
logger.debug('Convo: init', {}, logger.DebugContext.convo)
|
||||
|
||||
if (
|
||||
this.status === ConvoStatus.Uninitialized ||
|
||||
this.status === ConvoStatus.Error
|
||||
) {
|
||||
try {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.commit()
|
||||
|
||||
await this.refreshConvo()
|
||||
this.status = ConvoStatus.Ready
|
||||
this.commit()
|
||||
|
||||
await this.fetchMessageHistory()
|
||||
|
||||
this.pollEvents()
|
||||
} catch (e: any) {
|
||||
logger.error('Convo: failed to init')
|
||||
this.error = {
|
||||
exception: e,
|
||||
code: ConvoError.InitFailed,
|
||||
retry: () => {
|
||||
this.error = undefined
|
||||
this.init()
|
||||
try {
|
||||
const response = await this.agent.api.chat.bsky.convo.getConvo(
|
||||
{
|
||||
convoId: this.convoId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
}
|
||||
this.status = ConvoStatus.Error
|
||||
this.commit()
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Convo: cannot init from ${this.status}`)
|
||||
},
|
||||
)
|
||||
const {convo} = response.data
|
||||
|
||||
this.convo = convo
|
||||
this.sender = this.convo.members.find(
|
||||
m => m.did === this.__tempFromUserDid,
|
||||
)
|
||||
this.status = ConvoStatus.Ready
|
||||
|
||||
this.commit()
|
||||
|
||||
await this.fetchMessageHistory()
|
||||
|
||||
this.pollEvents()
|
||||
} catch (e) {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = e
|
||||
}
|
||||
}
|
||||
|
||||
async resume() {
|
||||
logger.debug('Convo: resume', {}, logger.DebugContext.convo)
|
||||
private async pollEvents() {
|
||||
if (this.status === ConvoStatus.Destroyed) return
|
||||
if (this.pendingEventIngestion) return
|
||||
setTimeout(async () => {
|
||||
this.pendingEventIngestion = this.ingestLatestEvents()
|
||||
await this.pendingEventIngestion
|
||||
this.pendingEventIngestion = undefined
|
||||
this.pollEvents()
|
||||
}, 5e3)
|
||||
}
|
||||
|
||||
if (
|
||||
this.status === ConvoStatus.Suspended ||
|
||||
this.status === ConvoStatus.Backgrounded
|
||||
) {
|
||||
const fromStatus = this.status
|
||||
async fetchMessageHistory() {
|
||||
if (this.status === ConvoStatus.Destroyed) return
|
||||
// reached end
|
||||
if (this.historyCursor === null) return
|
||||
if (this.isFetchingHistory) return
|
||||
|
||||
try {
|
||||
this.status = ConvoStatus.Resuming
|
||||
this.commit()
|
||||
this.isFetchingHistory = true
|
||||
this.commit()
|
||||
|
||||
await this.refreshConvo()
|
||||
this.status = ConvoStatus.Ready
|
||||
this.commit()
|
||||
|
||||
// throw new Error('UNCOMMENT TO TEST RESUME FAILURE')
|
||||
|
||||
this.pollInterval = ACTIVE_POLL_INTERVAL
|
||||
this.pollEvents()
|
||||
} catch (e) {
|
||||
logger.error('Convo: failed to resume')
|
||||
|
||||
this.footerItems.set(ConvoItemError.ResumeFailed, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoItemError.ResumeFailed,
|
||||
code: ConvoItemError.ResumeFailed,
|
||||
retry: () => {
|
||||
this.footerItems.delete(ConvoItemError.ResumeFailed)
|
||||
this.resume()
|
||||
},
|
||||
})
|
||||
|
||||
this.status = fromStatus
|
||||
this.commit()
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Convo: cannot resume from ${this.status}`)
|
||||
/*
|
||||
* Delay if paginating while scrolled.
|
||||
*
|
||||
* TODO why does the FlatList jump without this delay?
|
||||
*
|
||||
* Tbh it feels a little more natural with a slight delay.
|
||||
*/
|
||||
if (this.pastMessages.size > 0) {
|
||||
await new Promise(y => setTimeout(y, 500))
|
||||
}
|
||||
}
|
||||
|
||||
async background() {
|
||||
logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo)
|
||||
this.status = ConvoStatus.Backgrounded
|
||||
this.pollInterval = BACKGROUND_POLL_INTERVAL
|
||||
this.commit()
|
||||
}
|
||||
|
||||
async suspend() {
|
||||
logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
|
||||
this.status = ConvoStatus.Suspended
|
||||
this.commit()
|
||||
}
|
||||
|
||||
async refreshConvo() {
|
||||
const response = await this.agent.api.chat.bsky.convo.getConvo(
|
||||
const response = await this.agent.api.chat.bsky.convo.getMessages(
|
||||
{
|
||||
cursor: this.historyCursor,
|
||||
convoId: this.convoId,
|
||||
limit: isNative ? 25 : 50,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -419,244 +194,100 @@ export class Convo {
|
||||
},
|
||||
},
|
||||
)
|
||||
this.convo = response.data.convo
|
||||
this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid)
|
||||
this.recipients = this.convo.members.filter(
|
||||
m => m.did !== this.__tempFromUserDid,
|
||||
)
|
||||
const {cursor, messages} = response.data
|
||||
|
||||
/*
|
||||
* Prevent invalid states
|
||||
*/
|
||||
if (!this.sender) {
|
||||
throw new Error('Convo: could not find sender in convo')
|
||||
}
|
||||
if (!this.recipients) {
|
||||
throw new Error('Convo: could not find recipients in convo')
|
||||
}
|
||||
}
|
||||
this.historyCursor = cursor || null
|
||||
|
||||
async fetchMessageHistory() {
|
||||
logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo)
|
||||
for (const message of messages) {
|
||||
if (
|
||||
ChatBskyConvoDefs.isMessageView(message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(message)
|
||||
) {
|
||||
this.pastMessages.set(message.id, message)
|
||||
|
||||
/*
|
||||
* If historyCursor is null, we've fetched all history.
|
||||
*/
|
||||
if (this.historyCursor === null) return
|
||||
|
||||
/*
|
||||
* Don't fetch again if a fetch is already in progress
|
||||
*/
|
||||
if (this.isFetchingHistory) return
|
||||
|
||||
/*
|
||||
* If we've rendered a retry state for history fetching, exit. Upon retry,
|
||||
* this will be removed and we'll try again.
|
||||
*/
|
||||
if (this.headerItems.has(ConvoItemError.HistoryFailed)) return
|
||||
|
||||
try {
|
||||
this.isFetchingHistory = true
|
||||
this.commit()
|
||||
|
||||
/*
|
||||
* Delay if paginating while scrolled to prevent momentum scrolling from
|
||||
* jerking the list around, plus makes it feel a little more human.
|
||||
*/
|
||||
if (this.pastMessages.size > 0) {
|
||||
await new Promise(y => setTimeout(y, 500))
|
||||
// throw new Error('UNCOMMENT TO TEST RETRY')
|
||||
}
|
||||
|
||||
const response = await this.agent.api.chat.bsky.convo.getMessages(
|
||||
{
|
||||
cursor: this.historyCursor,
|
||||
convoId: this.convoId,
|
||||
limit: isNative ? 25 : 50,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
const {cursor, messages} = response.data
|
||||
|
||||
this.historyCursor = cursor ?? null
|
||||
|
||||
for (const message of messages) {
|
||||
// set to latest rev
|
||||
if (
|
||||
ChatBskyConvoDefs.isMessageView(message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(message)
|
||||
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
|
||||
) {
|
||||
this.pastMessages.set(message.id, message)
|
||||
|
||||
// set to latest rev
|
||||
if (
|
||||
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
|
||||
) {
|
||||
this.eventsCursor = message.rev
|
||||
}
|
||||
this.eventsCursor = message.rev
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Convo: failed to fetch message history')
|
||||
|
||||
this.headerItems.set(ConvoItemError.HistoryFailed, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoItemError.HistoryFailed,
|
||||
code: ConvoItemError.HistoryFailed,
|
||||
retry: () => {
|
||||
this.headerItems.delete(ConvoItemError.HistoryFailed)
|
||||
this.fetchMessageHistory()
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
this.isFetchingHistory = false
|
||||
this.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private async pollEvents() {
|
||||
if (
|
||||
this.status === ConvoStatus.Ready ||
|
||||
this.status === ConvoStatus.Backgrounded
|
||||
) {
|
||||
if (this.pendingEventIngestion) return
|
||||
|
||||
/*
|
||||
* Represents a failed state, which is retryable.
|
||||
*/
|
||||
if (this.pollingFailure) return
|
||||
|
||||
setTimeout(async () => {
|
||||
this.pendingEventIngestion = this.ingestLatestEvents()
|
||||
await this.pendingEventIngestion
|
||||
this.pendingEventIngestion = undefined
|
||||
this.pollEvents()
|
||||
}, this.pollInterval)
|
||||
}
|
||||
this.isFetchingHistory = false
|
||||
this.commit()
|
||||
}
|
||||
|
||||
async ingestLatestEvents() {
|
||||
try {
|
||||
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
|
||||
const response = await this.agent.api.chat.bsky.convo.getLog(
|
||||
{
|
||||
cursor: this.eventsCursor,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
const {logs} = response.data
|
||||
if (this.status === ConvoStatus.Destroyed) return
|
||||
|
||||
let needsCommit = false
|
||||
const response = await this.agent.api.chat.bsky.convo.getLog(
|
||||
{
|
||||
cursor: this.eventsCursor,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
const {logs} = response.data
|
||||
|
||||
for (const log of logs) {
|
||||
for (const log of logs) {
|
||||
/*
|
||||
* If there's a rev, we should handle it. If there's not a rev, we don't
|
||||
* know what it is.
|
||||
*/
|
||||
if (typeof log.rev === 'string') {
|
||||
/*
|
||||
* If there's a rev, we should handle it. If there's not a rev, we don't
|
||||
* know what it is.
|
||||
* We only care about new events
|
||||
*/
|
||||
if (typeof log.rev === 'string') {
|
||||
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
|
||||
/*
|
||||
* We only care about new events
|
||||
* Update rev regardless of if it's a log type we care about or not
|
||||
*/
|
||||
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
|
||||
/*
|
||||
* Update rev regardless of if it's a log type we care about or not
|
||||
*/
|
||||
this.eventsCursor = log.rev
|
||||
this.eventsCursor = log.rev
|
||||
|
||||
/*
|
||||
* This is VERY important. We don't want to insert any messages from
|
||||
* your other chats.
|
||||
*/
|
||||
if (log.convoId !== this.convoId) continue
|
||||
/*
|
||||
* This is VERY important. We don't want to insert any messages from
|
||||
* your other chats.
|
||||
*/
|
||||
if (log.convoId !== this.convoId) continue
|
||||
|
||||
if (
|
||||
ChatBskyConvoDefs.isLogCreateMessage(log) &&
|
||||
ChatBskyConvoDefs.isMessageView(log.message)
|
||||
) {
|
||||
if (this.newMessages.has(log.message.id)) {
|
||||
// Trust the log as the source of truth on ordering
|
||||
this.newMessages.delete(log.message.id)
|
||||
}
|
||||
this.newMessages.set(log.message.id, log.message)
|
||||
needsCommit = true
|
||||
} else if (
|
||||
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
|
||||
ChatBskyConvoDefs.isDeletedMessageView(log.message)
|
||||
) {
|
||||
if (
|
||||
ChatBskyConvoDefs.isLogCreateMessage(log) &&
|
||||
ChatBskyConvoDefs.isMessageView(log.message)
|
||||
) {
|
||||
if (this.newMessages.has(log.message.id)) {
|
||||
// Trust the log as the source of truth on ordering
|
||||
this.newMessages.delete(log.message.id)
|
||||
}
|
||||
this.newMessages.set(log.message.id, log.message)
|
||||
} else if (
|
||||
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
|
||||
ChatBskyConvoDefs.isDeletedMessageView(log.message)
|
||||
) {
|
||||
/*
|
||||
* Update if we have this in state. If we don't, don't worry about it.
|
||||
*/
|
||||
if (this.pastMessages.has(log.message.id)) {
|
||||
/*
|
||||
* Update if we have this in state. If we don't, don't worry about it.
|
||||
* For now, we remove deleted messages from the thread, if we receive one.
|
||||
*
|
||||
* To support them, it'd look something like this:
|
||||
* this.pastMessages.set(log.message.id, log.message)
|
||||
*/
|
||||
if (this.pastMessages.has(log.message.id)) {
|
||||
/*
|
||||
* For now, we remove deleted messages from the thread, if we receive one.
|
||||
*
|
||||
* To support them, it'd look something like this:
|
||||
* this.pastMessages.set(log.message.id, log.message)
|
||||
*/
|
||||
this.pastMessages.delete(log.message.id)
|
||||
this.newMessages.delete(log.message.id)
|
||||
this.deletedMessages.delete(log.message.id)
|
||||
needsCommit = true
|
||||
}
|
||||
this.pastMessages.delete(log.message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsCommit) {
|
||||
this.commit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Convo: failed to poll events')
|
||||
this.pollingFailure = true
|
||||
this.footerItems.set(ConvoItemError.PollFailed, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoItemError.PollFailed,
|
||||
code: ConvoItemError.PollFailed,
|
||||
retry: () => {
|
||||
this.footerItems.delete(ConvoItemError.PollFailed)
|
||||
this.pollingFailure = false
|
||||
this.commit()
|
||||
this.pollEvents()
|
||||
},
|
||||
})
|
||||
this.commit()
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
|
||||
// Ignore empty messages for now since they have no other purpose atm
|
||||
if (!message.text.trim()) return
|
||||
|
||||
logger.debug('Convo: send message', {}, logger.DebugContext.convo)
|
||||
|
||||
const tempId = nanoid()
|
||||
|
||||
this.pendingMessages.set(tempId, {
|
||||
id: tempId,
|
||||
message,
|
||||
})
|
||||
this.commit()
|
||||
|
||||
if (!this.isProcessingPendingMessages) {
|
||||
this.processPendingMessages()
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingMessages() {
|
||||
logger.debug(
|
||||
`Convo: processing messages (${this.pendingMessages.size} remaining)`,
|
||||
{},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
|
||||
const pendingMessage = Array.from(this.pendingMessages.values()).shift()
|
||||
|
||||
/*
|
||||
@@ -712,12 +343,6 @@ export class Convo {
|
||||
}
|
||||
|
||||
async batchRetryPendingMessages() {
|
||||
logger.debug(
|
||||
`Convo: retrying ${this.pendingMessages.size} pending messages`,
|
||||
{},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
|
||||
this.footerItems.delete('pending-retry')
|
||||
this.commit()
|
||||
|
||||
@@ -768,70 +393,41 @@ export class Convo {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: string) {
|
||||
logger.debug('Convo: delete message', {}, logger.DebugContext.convo)
|
||||
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
|
||||
if (this.status === ConvoStatus.Destroyed) return
|
||||
// Ignore empty messages for now since they have no other purpose atm
|
||||
if (!message.text.trim()) return
|
||||
|
||||
this.deletedMessages.add(messageId)
|
||||
const tempId = nanoid()
|
||||
|
||||
this.pendingMessages.set(tempId, {
|
||||
id: tempId,
|
||||
message,
|
||||
})
|
||||
this.commit()
|
||||
|
||||
try {
|
||||
await this.agent.api.chat.bsky.convo.deleteMessageForSelf(
|
||||
{
|
||||
convoId: this.convoId,
|
||||
messageId,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
this.deletedMessages.delete(messageId)
|
||||
this.commit()
|
||||
throw e
|
||||
if (!this.isProcessingPendingMessages) {
|
||||
this.processPendingMessages()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Items in reverse order, since FlatList inverts
|
||||
*/
|
||||
getItems(): ConvoItem[] {
|
||||
get items(): ConvoItem[] {
|
||||
const items: ConvoItem[] = []
|
||||
|
||||
this.headerItems.forEach(item => {
|
||||
items.push(item)
|
||||
})
|
||||
|
||||
this.pastMessages.forEach(m => {
|
||||
if (ChatBskyConvoDefs.isMessageView(m)) {
|
||||
items.unshift({
|
||||
type: 'message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
nextMessage: null,
|
||||
})
|
||||
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
||||
items.unshift({
|
||||
type: 'deleted-message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
nextMessage: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// `newMessages` is in insertion order, unshift to reverse
|
||||
this.newMessages.forEach(m => {
|
||||
if (ChatBskyConvoDefs.isMessageView(m)) {
|
||||
items.push({
|
||||
items.unshift({
|
||||
type: 'message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
nextMessage: null,
|
||||
})
|
||||
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
||||
items.push({
|
||||
items.unshift({
|
||||
type: 'deleted-message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
@@ -840,8 +436,9 @@ export class Convo {
|
||||
}
|
||||
})
|
||||
|
||||
// `newMessages` is in insertion order, unshift to reverse
|
||||
this.pendingMessages.forEach(m => {
|
||||
items.push({
|
||||
items.unshift({
|
||||
type: 'pending-message',
|
||||
key: m.id,
|
||||
message: {
|
||||
@@ -856,45 +453,109 @@ export class Convo {
|
||||
})
|
||||
|
||||
this.footerItems.forEach(item => {
|
||||
items.push(item)
|
||||
items.unshift(item)
|
||||
})
|
||||
|
||||
return items
|
||||
.filter(item => {
|
||||
if (isConvoItemMessage(item)) {
|
||||
return !this.deletedMessages.has(item.message.id)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((item, i, arr) => {
|
||||
let nextMessage = null
|
||||
const isMessage = isConvoItemMessage(item)
|
||||
this.pastMessages.forEach(m => {
|
||||
if (ChatBskyConvoDefs.isMessageView(m)) {
|
||||
items.push({
|
||||
type: 'message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
nextMessage: null,
|
||||
})
|
||||
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
||||
items.push({
|
||||
type: 'deleted-message',
|
||||
key: m.id,
|
||||
message: m,
|
||||
nextMessage: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return items.map((item, i) => {
|
||||
let nextMessage = null
|
||||
const isMessage = isConvoItemMessage(item)
|
||||
|
||||
if (isMessage) {
|
||||
if (
|
||||
isMessage &&
|
||||
(ChatBskyConvoDefs.isMessageView(item.message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(item.message))
|
||||
) {
|
||||
const next = items[i - 1]
|
||||
|
||||
if (isMessage) {
|
||||
if (
|
||||
isMessage &&
|
||||
(ChatBskyConvoDefs.isMessageView(item.message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(item.message))
|
||||
isConvoItemMessage(next) &&
|
||||
next &&
|
||||
(ChatBskyConvoDefs.isMessageView(next.message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
|
||||
) {
|
||||
const next = arr[i + 1]
|
||||
|
||||
if (
|
||||
isConvoItemMessage(next) &&
|
||||
next &&
|
||||
(ChatBskyConvoDefs.isMessageView(next.message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
|
||||
) {
|
||||
nextMessage = next.message
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
nextMessage,
|
||||
nextMessage = next.message
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
return {
|
||||
...item,
|
||||
nextMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.status = ConvoStatus.Destroyed
|
||||
this.commit()
|
||||
}
|
||||
|
||||
get state(): ConvoState {
|
||||
switch (this.status) {
|
||||
case ConvoStatus.Initializing: {
|
||||
return {
|
||||
status: ConvoStatus.Initializing,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Ready: {
|
||||
return {
|
||||
status: ConvoStatus.Ready,
|
||||
items: this.items,
|
||||
convo: this.convo!,
|
||||
isFetchingHistory: this.isFetchingHistory,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Error: {
|
||||
return {
|
||||
status: ConvoStatus.Error,
|
||||
error: this.error,
|
||||
}
|
||||
}
|
||||
case ConvoStatus.Destroyed: {
|
||||
return {
|
||||
status: ConvoStatus.Destroyed,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: ConvoStatus.Uninitialized,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitter = new EventEmitter()
|
||||
|
||||
private commit() {
|
||||
this._emitter.emit('update')
|
||||
}
|
||||
|
||||
on(event: 'update', cb: () => void) {
|
||||
this._emitter.on(event, cb)
|
||||
}
|
||||
|
||||
off(event: 'update', cb: () => void) {
|
||||
this._emitter.off(event, cb)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, {useContext, useState, useSyncExternalStore} from 'react'
|
||||
import React, {useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
|
||||
import {Convo, ConvoParams} from '#/state/messages/convo'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
|
||||
const ChatContext = React.createContext<ConvoState | null>(null)
|
||||
const ChatContext = React.createContext<{
|
||||
service: Convo
|
||||
state: Convo['state']
|
||||
} | null>(null)
|
||||
|
||||
export function useChat() {
|
||||
const ctx = useContext(ChatContext)
|
||||
@@ -22,7 +24,7 @@ export function ChatProvider({
|
||||
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const {getAgent} = useAgent()
|
||||
const [convo] = useState(
|
||||
const [service] = useState(
|
||||
() =>
|
||||
new Convo({
|
||||
convoId,
|
||||
@@ -32,17 +34,21 @@ export function ChatProvider({
|
||||
__tempFromUserDid: getAgent().session?.did!,
|
||||
}),
|
||||
)
|
||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||
const [state, setState] = useState(service.state)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
convo.resume()
|
||||
useEffect(() => {
|
||||
service.initialize()
|
||||
}, [service])
|
||||
|
||||
return () => {
|
||||
convo.background()
|
||||
}
|
||||
}, [convo]),
|
||||
)
|
||||
useEffect(() => {
|
||||
const update = () => setState(service.state)
|
||||
service.on('update', update)
|
||||
return () => {
|
||||
service.destroy()
|
||||
}
|
||||
}, [service])
|
||||
|
||||
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
|
||||
const value = useMemo(() => ({service, state}), [service, state])
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useHeaders} from './temp-headers'
|
||||
export const RQKEY = ['convo-list']
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useListConvos({refetchInterval}: {refetchInterval: number}) {
|
||||
export function useListConvos() {
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
|
||||
@@ -24,6 +24,5 @@ export function useListConvos({refetchInterval}: {refetchInterval: number}) {
|
||||
},
|
||||
initialPageParam: undefined as RQPageParam,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
+227
-209
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
|
||||
import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
@@ -35,6 +35,8 @@ const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
configureModerationForGuest()
|
||||
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
isInitialLoad: true,
|
||||
isSwitchingAccounts: false,
|
||||
accounts: [],
|
||||
currentAccount: undefined,
|
||||
hasSession: false,
|
||||
@@ -45,7 +47,9 @@ const ApiContext = React.createContext<SessionApiContext>({
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
initSession: async () => {},
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
selectAccount: async () => {},
|
||||
updateCurrentAccount: () => {},
|
||||
clearCurrentAccount: () => {},
|
||||
})
|
||||
@@ -56,26 +60,33 @@ function __getAgent() {
|
||||
return __globalAgent
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: BskyAgent
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
type State = {
|
||||
accounts: SessionStateContext['accounts']
|
||||
currentAgentState: AgentState
|
||||
currentAccountDid: string | undefined
|
||||
needsPersist: boolean
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [state, setState] = React.useState<State>(() => ({
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
||||
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
|
||||
const [state, setState] = React.useState<State>({
|
||||
accounts: persisted.get('session').accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined, // assume logged out to start
|
||||
},
|
||||
currentAccountDid: undefined, // assume logged out to start
|
||||
needsPersist: false,
|
||||
}))
|
||||
})
|
||||
|
||||
const upsertAccount = React.useCallback(
|
||||
(account: SessionAccount, expired = false) => {
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAccountDid: expired ? undefined : account.did,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
const clearCurrentAccount = React.useCallback(() => {
|
||||
logger.warn(`session: clear current account`)
|
||||
@@ -83,112 +94,80 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
currentAccountDid: undefined,
|
||||
needsPersist: true,
|
||||
}))
|
||||
}, [setState])
|
||||
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
const createPersistSessionHandler = React.useCallback(
|
||||
(
|
||||
agent: BskyAgent,
|
||||
account: SessionAccount,
|
||||
event: AtpSessionEvent,
|
||||
session: AtpSessionData | undefined,
|
||||
) => {
|
||||
const expired = event === 'expired' || event === 'create-failed'
|
||||
persistSessionCallback: (props: {
|
||||
expired: boolean
|
||||
refreshedAccount: SessionAccount
|
||||
}) => void,
|
||||
{
|
||||
networkErrorCallback,
|
||||
}: {
|
||||
networkErrorCallback?: () => void
|
||||
} = {},
|
||||
): AtpPersistSessionHandler => {
|
||||
return function persistSession(event, session) {
|
||||
const expired = event === 'expired' || event === 'create-failed'
|
||||
|
||||
if (event === 'network-error') {
|
||||
logger.warn(
|
||||
`session: persistSessionHandler received network-error event`,
|
||||
)
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (event === 'network-error') {
|
||||
logger.warn(
|
||||
`session: persistSessionHandler received network-error event`,
|
||||
)
|
||||
networkErrorCallback?.()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: use agentToSessionAccount for this too.
|
||||
const refreshedAccount: SessionAccount = {
|
||||
service: account.service,
|
||||
did: session?.did ?? account.did,
|
||||
handle: session?.handle ?? account.handle,
|
||||
email: session?.email ?? account.email,
|
||||
emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed,
|
||||
emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor,
|
||||
deactivated: isSessionDeactivated(session?.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
// TODO: use agentToSessionAccount for this too.
|
||||
const refreshedAccount: SessionAccount = {
|
||||
service: account.service,
|
||||
did: session?.did || account.did,
|
||||
handle: session?.handle || account.handle,
|
||||
email: session?.email || account.email,
|
||||
emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
|
||||
emailAuthFactor: session?.emailAuthFactor || account.emailAuthFactor,
|
||||
deactivated: isSessionDeactivated(session?.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
|
||||
/*
|
||||
* Tokens are undefined if the session expires, or if creation fails for
|
||||
* any reason e.g. tokens are invalid, network error, etc.
|
||||
*/
|
||||
refreshJwt: session?.refreshJwt,
|
||||
accessJwt: session?.accessJwt,
|
||||
}
|
||||
|
||||
logger.debug(`session: persistSession`, {
|
||||
event,
|
||||
deactivated: refreshedAccount.deactivated,
|
||||
})
|
||||
|
||||
if (expired) {
|
||||
logger.warn(`session: expired`)
|
||||
emitSessionDropped()
|
||||
}
|
||||
|
||||
/*
|
||||
* Tokens are undefined if the session expires, or if creation fails for
|
||||
* any reason e.g. tokens are invalid, network error, etc.
|
||||
* If the session expired, or it was successfully created/updated, we want
|
||||
* to update/persist the data.
|
||||
*
|
||||
* If the session creation failed, it could be a network error, or it could
|
||||
* be more serious like an invalid token(s). We can't differentiate, so in
|
||||
* order to allow the user to get a fresh token (if they need it), we need
|
||||
* to persist this data and wipe their tokens, effectively logging them
|
||||
* out.
|
||||
*/
|
||||
refreshJwt: session?.refreshJwt,
|
||||
accessJwt: session?.accessJwt,
|
||||
persistSessionCallback({
|
||||
expired,
|
||||
refreshedAccount,
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug(`session: persistSession`, {
|
||||
event,
|
||||
deactivated: refreshedAccount.deactivated,
|
||||
})
|
||||
|
||||
if (expired) {
|
||||
logger.warn(`session: expired`)
|
||||
emitSessionDropped()
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/*
|
||||
* If the session expired, or it was successfully created/updated, we want
|
||||
* to update/persist the data.
|
||||
*
|
||||
* If the session creation failed, it could be a network error, or it could
|
||||
* be more serious like an invalid token(s). We can't differentiate, so in
|
||||
* order to allow the user to get a fresh token (if they need it), we need
|
||||
* to persist this data and wipe their tokens, effectively logging them
|
||||
* out.
|
||||
*/
|
||||
setState(s => {
|
||||
const existingAccount = s.accounts.find(
|
||||
a => a.did === refreshedAccount.did,
|
||||
)
|
||||
if (
|
||||
!expired &&
|
||||
existingAccount &&
|
||||
refreshedAccount &&
|
||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||
) {
|
||||
// Fast path without a state update.
|
||||
return s
|
||||
}
|
||||
return {
|
||||
accounts: [
|
||||
refreshedAccount,
|
||||
...s.accounts.filter(a => a.did !== refreshedAccount.did),
|
||||
],
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
@@ -218,28 +197,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
)
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
upsertAccount(account)
|
||||
|
||||
logger.debug(`session: created account`, {}, logger.DebugContext.session)
|
||||
track('Create Account')
|
||||
logEvent('account:create:success', {})
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
)
|
||||
|
||||
const login = React.useCallback<SessionApiContext['login']>(
|
||||
@@ -252,39 +229,35 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
authFactorToken,
|
||||
})
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
|
||||
__globalAgent = agent
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
upsertAccount(account)
|
||||
|
||||
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
|
||||
|
||||
track('Sign In', {resumedSession: false})
|
||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
)
|
||||
|
||||
const logout = React.useCallback<SessionApiContext['logout']>(
|
||||
async logContext => {
|
||||
logger.debug(`session: logout`)
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
clearCurrentAccount()
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.map(a => ({
|
||||
@@ -292,16 +265,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
})),
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
logEvent('account:loggedOut', {logContext})
|
||||
},
|
||||
[setState],
|
||||
[clearCurrentAccount, setState],
|
||||
)
|
||||
|
||||
const initSession = React.useCallback<SessionApiContext['initSession']>(
|
||||
@@ -316,9 +286,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
|
||||
}
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
@@ -328,8 +305,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
isSessionDeactivated(account.accessJwt) || account.deactivated
|
||||
|
||||
const prevSession = {
|
||||
accessJwt: account.accessJwt ?? '',
|
||||
refreshJwt: account.refreshJwt ?? '',
|
||||
accessJwt: account.accessJwt || '',
|
||||
refreshJwt: account.refreshJwt || '',
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
}
|
||||
@@ -337,22 +314,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (isSessionExpired(account)) {
|
||||
logger.debug(`session: attempting to resume using previous session`)
|
||||
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
freshAccount,
|
||||
...s.accounts.filter(a => a.did !== freshAccount.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: freshAccount.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
try {
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
upsertAccount(freshAccount)
|
||||
} catch (e) {
|
||||
/*
|
||||
* Note: `agent.persistSession` is also called when this fails, and
|
||||
* we handle that failure via `createPersistSessionHandler`
|
||||
*/
|
||||
logger.info(`session: resumeSessionWithFreshAccount failed`, {
|
||||
message: e,
|
||||
})
|
||||
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
// TODO: Should this update currentAccountDid?
|
||||
}
|
||||
} else {
|
||||
logger.debug(`session: attempting to reuse previous session`)
|
||||
|
||||
@@ -360,19 +338,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
account,
|
||||
...s.accounts.filter(a => a.did !== account.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
upsertAccount(account)
|
||||
|
||||
if (accountOrSessionDeactivated) {
|
||||
// don't attempt to resume
|
||||
@@ -383,6 +349,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
resumeSessionWithFreshAccount()
|
||||
.then(freshAccount => {
|
||||
if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
|
||||
logger.info(
|
||||
`session: reuse of previous session returned a fresh account, upserting`,
|
||||
)
|
||||
upsertAccount(freshAccount)
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
/*
|
||||
* Note: `agent.persistSession` is also called when this fails, and
|
||||
* we handle that failure via `createPersistSessionHandler`
|
||||
*/
|
||||
logger.info(`session: resumeSessionWithFreshAccount failed`, {
|
||||
message: e,
|
||||
})
|
||||
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
// TODO: Should this update currentAccountDid?
|
||||
})
|
||||
}
|
||||
|
||||
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
||||
@@ -400,7 +386,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return sessionAccount
|
||||
}
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async account => {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
},
|
||||
[initSession],
|
||||
)
|
||||
|
||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||
@@ -408,7 +409,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.filter(a => a.did !== account.did),
|
||||
currentAgentState: s.currentAgentState,
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
@@ -422,19 +423,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account => {
|
||||
setState(s => {
|
||||
const currentAccount = s.accounts.find(
|
||||
a => a.did === s.currentAgentState.did,
|
||||
a => a.did === s.currentAccountDid,
|
||||
)
|
||||
// ignore, should never happen
|
||||
if (!currentAccount) return s
|
||||
|
||||
const updatedAccount = {
|
||||
...currentAccount,
|
||||
handle: account.handle ?? currentAccount.handle,
|
||||
email: account.email ?? currentAccount.email,
|
||||
handle: account.handle || currentAccount.handle,
|
||||
email: account.email || currentAccount.email,
|
||||
emailConfirmed:
|
||||
account.emailConfirmed ?? currentAccount.emailConfirmed,
|
||||
account.emailConfirmed !== undefined
|
||||
? account.emailConfirmed
|
||||
: currentAccount.emailConfirmed,
|
||||
emailAuthFactor:
|
||||
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
|
||||
account.emailAuthFactor !== undefined
|
||||
? account.emailAuthFactor
|
||||
: currentAccount.emailAuthFactor,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -442,7 +447,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
updatedAccount,
|
||||
...s.accounts.filter(a => a.did !== currentAccount.did),
|
||||
],
|
||||
currentAgentState: s.currentAgentState,
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
@@ -450,13 +455,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[setState],
|
||||
)
|
||||
|
||||
const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
|
||||
async (account, logContext) => {
|
||||
setIsSwitchingAccounts(true)
|
||||
try {
|
||||
await initSession(account)
|
||||
setIsSwitchingAccounts(false)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
} catch (e) {
|
||||
// reset this in case of error
|
||||
setIsSwitchingAccounts(false)
|
||||
// but other listeners need a throw
|
||||
throw e
|
||||
}
|
||||
},
|
||||
[initSession],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.needsPersist) {
|
||||
state.needsPersist = false
|
||||
persisted.write('session', {
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAgentState.did,
|
||||
a => a.did === state.currentAccountDid,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -467,21 +489,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const persistedSession = persisted.get('session')
|
||||
|
||||
logger.debug(`session: persisted onUpdate`, {})
|
||||
setState(s => ({
|
||||
accounts: persistedSession.accounts,
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
|
||||
const selectedAccount = persistedSession.accounts.find(
|
||||
a => a.did === persistedSession.currentAccount?.did,
|
||||
)
|
||||
|
||||
if (selectedAccount && selectedAccount.refreshJwt) {
|
||||
if (selectedAccount.did !== state.currentAgentState.did) {
|
||||
if (selectedAccount.did !== state.currentAccountDid) {
|
||||
logger.debug(`session: persisted onUpdate, switching accounts`, {
|
||||
from: {
|
||||
did: state.currentAgentState.did,
|
||||
did: state.currentAccountDid,
|
||||
},
|
||||
to: {
|
||||
did: selectedAccount.did,
|
||||
@@ -499,9 +516,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
*/
|
||||
// @ts-ignore we checked for `refreshJwt` above
|
||||
__globalAgent.session = selectedAccount
|
||||
// TODO: This needs a setState.
|
||||
}
|
||||
} else if (!selectedAccount && state.currentAgentState.did) {
|
||||
} else if (!selectedAccount && state.currentAccountDid) {
|
||||
logger.debug(
|
||||
`session: persisted onUpdate, logging out`,
|
||||
{},
|
||||
@@ -514,30 +530,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
* handled by `persistSession` (which nukes this accounts tokens only),
|
||||
* or by a `logout` call which nukes all accounts tokens)
|
||||
*/
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
clearCurrentAccount()
|
||||
}
|
||||
|
||||
setState(() => ({
|
||||
accounts: persistedSession.accounts,
|
||||
currentAccountDid: selectedAccount?.did,
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
})
|
||||
}, [state, setState, initSession])
|
||||
}, [state, setState, clearCurrentAccount, initSession])
|
||||
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAgentState.did,
|
||||
a => a.did === state.currentAccountDid,
|
||||
),
|
||||
hasSession: !!state.currentAgentState.did,
|
||||
isInitialLoad,
|
||||
isSwitchingAccounts,
|
||||
hasSession: !!state.currentAccountDid,
|
||||
}),
|
||||
[state],
|
||||
[state, isInitialLoad, isSwitchingAccounts],
|
||||
)
|
||||
|
||||
const api = React.useMemo(
|
||||
@@ -546,7 +560,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
selectAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
}),
|
||||
@@ -555,7 +571,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
selectAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
],
|
||||
|
||||
@@ -6,6 +6,8 @@ export type SessionAccount = PersistedAccount
|
||||
export type SessionStateContext = {
|
||||
accounts: SessionAccount[]
|
||||
currentAccount: SessionAccount | undefined
|
||||
isInitialLoad: boolean
|
||||
isSwitchingAccounts: boolean
|
||||
hasSession: boolean
|
||||
}
|
||||
export type SessionApiContext = {
|
||||
@@ -44,7 +46,12 @@ export type SessionApiContext = {
|
||||
*/
|
||||
clearCurrentAccount: () => void
|
||||
initSession: (account: SessionAccount) => Promise<void>
|
||||
resumeSession: (account?: SessionAccount) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
selectAccount: (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
updateCurrentAccount: (
|
||||
account: Partial<
|
||||
Pick<
|
||||
|
||||
@@ -59,7 +59,6 @@ import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {CharProgress} from './char-progress/CharProgress'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {GifAltText} from './GifAltText'
|
||||
import {LabelsBtn} from './labels/LabelsBtn'
|
||||
import {Gallery} from './photos/Gallery'
|
||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||
@@ -328,7 +327,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
image: gif.media_formats.preview.url,
|
||||
likelyType: LikelyType.HTML,
|
||||
title: gif.content_description,
|
||||
description: '',
|
||||
description: `ALT: ${gif.content_description}`,
|
||||
},
|
||||
})
|
||||
setExtGif(gif)
|
||||
@@ -336,26 +335,6 @@ export const ComposePost = observer(function ComposePost({
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
const handleChangeGifAltText = useCallback(
|
||||
(altText: string) => {
|
||||
setExtLink(ext =>
|
||||
ext && ext.meta
|
||||
? {
|
||||
...ext,
|
||||
meta: {
|
||||
...ext?.meta,
|
||||
description:
|
||||
altText.trim().length === 0
|
||||
? ''
|
||||
: `Alt text: ${altText.trim()}`,
|
||||
},
|
||||
}
|
||||
: ext,
|
||||
)
|
||||
},
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
@@ -495,21 +474,14 @@ export const ComposePost = observer(function ComposePost({
|
||||
|
||||
<Gallery gallery={gallery} />
|
||||
{gallery.isEmpty && extLink && (
|
||||
<View style={a.relative}>
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onRemove={() => {
|
||||
setExtLink(undefined)
|
||||
setExtGif(undefined)
|
||||
}}
|
||||
/>
|
||||
<GifAltText
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onSubmit={handleChangeGifAltText}
|
||||
/>
|
||||
</View>
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onRemove={() => {
|
||||
setExtLink(undefined)
|
||||
setExtGif(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{quote ? (
|
||||
<View style={[s.mt5, isWeb && s.mb10]}>
|
||||
|
||||
@@ -46,12 +46,7 @@ export const ExternalEmbed = ({
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
!gif && a.mb_xl,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
|
||||
{link.isLoading ? (
|
||||
<Container style={loadingStyle}>
|
||||
<Loader size="xl" />
|
||||
@@ -67,7 +62,7 @@ export const ExternalEmbed = ({
|
||||
</Container>
|
||||
) : linkInfo ? (
|
||||
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
|
||||
<ExternalLinkEmbed link={linkInfo} hideAlt />
|
||||
<ExternalLinkEmbed link={linkInfo} />
|
||||
</View>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ExternalEmbedDraft} from '#/lib/api'
|
||||
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {
|
||||
EmbedPlayerParams,
|
||||
parseEmbedPlayerFromUrl,
|
||||
} from '#/lib/strings/embed-player'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {GifEmbed} from '../util/post-embeds/GifEmbed'
|
||||
import {AltTextReminder} from './photos/Gallery'
|
||||
|
||||
export function GifAltText({
|
||||
link: linkProp,
|
||||
gif,
|
||||
onSubmit,
|
||||
}: {
|
||||
link: ExternalEmbedDraft
|
||||
gif?: Gif
|
||||
onSubmit: (alt: string) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {link, params} = React.useMemo(() => {
|
||||
return {
|
||||
link: {
|
||||
title: linkProp.meta?.title ?? linkProp.uri,
|
||||
uri: linkProp.uri,
|
||||
description: linkProp.meta?.description ?? '',
|
||||
thumb: linkProp.localThumb?.path,
|
||||
},
|
||||
params: parseEmbedPlayerFromUrl(linkProp.uri),
|
||||
}
|
||||
}, [linkProp])
|
||||
|
||||
const onPressSubmit = useCallback(
|
||||
(alt: string) => {
|
||||
control.close(() => {
|
||||
onSubmit(alt)
|
||||
})
|
||||
},
|
||||
[onSubmit, control],
|
||||
)
|
||||
|
||||
if (!gif || !params) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={control.open}
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: 20, left: 12},
|
||||
{borderRadius: 6},
|
||||
a.pl_xs,
|
||||
a.pr_sm,
|
||||
a.py_2xs,
|
||||
a.flex_row,
|
||||
a.gap_xs,
|
||||
a.align_center,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.75)'},
|
||||
]}>
|
||||
{link.description ? (
|
||||
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
|
||||
) : (
|
||||
<Plus size="sm" fill={t.palette.white} />
|
||||
)}
|
||||
<Text
|
||||
style={[a.font_bold, {color: t.palette.white}]}
|
||||
accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<AltTextReminder />
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
|
||||
<Dialog.Handle />
|
||||
<AltTextInner
|
||||
onSubmit={onPressSubmit}
|
||||
link={link}
|
||||
params={params}
|
||||
initalValue={link.description.replace('Alt text: ', '')}
|
||||
key={link.uri}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AltTextInner({
|
||||
onSubmit,
|
||||
link,
|
||||
params,
|
||||
initalValue,
|
||||
}: {
|
||||
onSubmit: (text: string) => void
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
initalValue: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [altText, setAltText] = useState(initalValue)
|
||||
|
||||
const onPressSubmit = useCallback(() => {
|
||||
onSubmit(altText)
|
||||
}, [onSubmit, altText])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
|
||||
<View style={a.flex_col_reverse}>
|
||||
<View style={[a.mt_md, a.gap_md]}>
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Descriptive alt text</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
label={_(msg`Alt text`)}
|
||||
placeholder={link.title}
|
||||
onChangeText={text =>
|
||||
setAltText(enforceLen(text, MAX_ALT_TEXT))
|
||||
}
|
||||
value={altText}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Save`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={onPressSubmit}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
{/* below the text input to force tab order */}
|
||||
<View>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Add ALT text</Trans>
|
||||
</Text>
|
||||
<View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
|
||||
<GifEmbed link={link} params={params} hideAlt />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import React, {useState} from 'react'
|
||||
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
const IMAGE_GAP = 8
|
||||
|
||||
@@ -50,10 +49,10 @@ const GalleryInner = observer(function GalleryImpl({
|
||||
gallery,
|
||||
containerInfo,
|
||||
}: GalleryInnerProps) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {openModal} = useModalControls()
|
||||
const t = useTheme()
|
||||
|
||||
let side: number
|
||||
|
||||
@@ -127,22 +126,16 @@ const GalleryInner = observer(function GalleryImpl({
|
||||
})
|
||||
}}
|
||||
style={[styles.altTextControl, altTextControlStyle]}>
|
||||
<Text style={styles.altTextControlLabel} accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
{image.altText.length > 0 ? (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
style={{color: colors.green3}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.altTextControlLabel} accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
<View style={imageControlsStyle}>
|
||||
<TouchableOpacity
|
||||
@@ -208,28 +201,21 @@ const GalleryInner = observer(function GalleryImpl({
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<AltTextReminder />
|
||||
<View style={[styles.reminder]}>
|
||||
<View style={[styles.infoIcon, pal.viewLight]}>
|
||||
<FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
|
||||
</View>
|
||||
<Text type="sm" style={[pal.textLight, s.flex1]}>
|
||||
<Trans>
|
||||
Alt text describes images for blind and low-vision users, and helps
|
||||
give context to everyone.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : null
|
||||
})
|
||||
|
||||
export function AltTextReminder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[styles.reminder]}>
|
||||
<View style={[styles.infoIcon, t.atoms.bg_contrast_25]}>
|
||||
<FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} />
|
||||
</View>
|
||||
<Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}>
|
||||
<Trans>
|
||||
Alt text describes images for blind and low-vision users, and helps
|
||||
give context to everyone.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gallery: {
|
||||
flex: 1,
|
||||
@@ -258,7 +244,6 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
altTextControlLabel: {
|
||||
color: 'white',
|
||||
|
||||
@@ -371,11 +371,11 @@ export function PostThread({
|
||||
],
|
||||
)
|
||||
|
||||
if (!thread || !preferences || error) {
|
||||
if (error || !thread) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={!error}
|
||||
isError={Boolean(error)}
|
||||
isLoading={(!preferences || !thread) && !error}
|
||||
isError={!!error}
|
||||
noEmpty
|
||||
onRetry={refetch}
|
||||
errorTitle={error?.title}
|
||||
|
||||
@@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {FlatList_INTERNAL} from './Views'
|
||||
|
||||
export type ListMethods = FlatList_INTERNAL
|
||||
@@ -23,7 +25,6 @@ export type ListProps<ItemT> = Omit<
|
||||
headerOffset?: number
|
||||
refreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
containWeb?: boolean
|
||||
}
|
||||
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
|
||||
|
||||
@@ -43,6 +44,7 @@ function ListImpl<ItemT>(
|
||||
const isScrolledDown = useSharedValue(false)
|
||||
const contextScrollHandlers = useScrollHandlers()
|
||||
const pal = usePalette('default')
|
||||
const gate = useGate()
|
||||
|
||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||
onScrolledDownChange?.(didScrollDown)
|
||||
@@ -104,6 +106,9 @@ function ListImpl<ItemT>(
|
||||
scrollEventThrottle={1}
|
||||
style={style}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={
|
||||
isWeb || !gate('hide_vertical_scroll_indicators')
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+28
-141
@@ -1,13 +1,11 @@
|
||||
import React, {isValidElement, memo, startTransition, useRef} from 'react'
|
||||
import React, {isValidElement, memo, useRef, startTransition} from 'react'
|
||||
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
||||
|
||||
import {batchedUpdates} from '#/lib/batchedUpdates'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {batchedUpdates} from '#/lib/batchedUpdates'
|
||||
|
||||
export type ListMethods = any // TODO: Better types.
|
||||
export type ListProps<ItemT> = Omit<
|
||||
@@ -21,7 +19,6 @@ export type ListProps<ItemT> = Omit<
|
||||
refreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
desktopFixedHeight: any // TODO: Better types.
|
||||
containWeb?: boolean
|
||||
}
|
||||
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
|
||||
|
||||
@@ -29,15 +26,12 @@ function ListImpl<ItemT>(
|
||||
{
|
||||
ListHeaderComponent,
|
||||
ListFooterComponent,
|
||||
containWeb,
|
||||
contentContainerStyle,
|
||||
data,
|
||||
desktopFixedHeight,
|
||||
headerOffset,
|
||||
keyExtractor,
|
||||
refreshing: _unsupportedRefreshing,
|
||||
onStartReached,
|
||||
onStartReachedThreshold = 0,
|
||||
onEndReached,
|
||||
onEndReachedThreshold = 0,
|
||||
onRefresh: _unsupportedOnRefresh,
|
||||
@@ -86,64 +80,14 @@ function ListImpl<ItemT>(
|
||||
})
|
||||
}
|
||||
|
||||
const getScrollableNode = React.useCallback(() => {
|
||||
if (containWeb) {
|
||||
const element = nativeRef.current as HTMLDivElement | null
|
||||
if (!element) return
|
||||
|
||||
return {
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientWidth: element.clientWidth,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollY: element.scrollTop,
|
||||
scrollX: element.scrollLeft,
|
||||
scrollTo(options?: ScrollToOptions) {
|
||||
element.scrollTo(options)
|
||||
},
|
||||
scrollBy(options: ScrollToOptions) {
|
||||
element.scrollBy(options)
|
||||
},
|
||||
addEventListener(event: string, handler: any) {
|
||||
element.addEventListener(event, handler)
|
||||
},
|
||||
removeEventListener(event: string, handler: any) {
|
||||
element.removeEventListener(event, handler)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
clientWidth: window.innerWidth,
|
||||
clientHeight: window.innerHeight,
|
||||
scrollY: window.scrollY,
|
||||
scrollX: window.scrollX,
|
||||
scrollTo(options: ScrollToOptions) {
|
||||
window.scrollTo(options)
|
||||
},
|
||||
scrollBy(options: ScrollToOptions) {
|
||||
window.scrollBy(options)
|
||||
},
|
||||
addEventListener(event: string, handler: any) {
|
||||
window.addEventListener(event, handler)
|
||||
},
|
||||
removeEventListener(event: string, handler: any) {
|
||||
window.removeEventListener(event, handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [containWeb])
|
||||
|
||||
const nativeRef = React.useRef(null)
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
({
|
||||
scrollToTop() {
|
||||
getScrollableNode()?.scrollTo({top: 0})
|
||||
window.scrollTo({top: 0})
|
||||
},
|
||||
|
||||
scrollToOffset({
|
||||
animated,
|
||||
offset,
|
||||
@@ -151,74 +95,46 @@ function ListImpl<ItemT>(
|
||||
animated: boolean
|
||||
offset: number
|
||||
}) {
|
||||
getScrollableNode()?.scrollTo({
|
||||
window.scrollTo({
|
||||
left: 0,
|
||||
top: offset,
|
||||
behavior: animated ? 'smooth' : 'instant',
|
||||
})
|
||||
},
|
||||
scrollToEnd({animated = true}: {animated?: boolean}) {
|
||||
const element = getScrollableNode()
|
||||
element?.scrollTo({
|
||||
left: 0,
|
||||
top: element.scrollHeight,
|
||||
behavior: animated ? 'smooth' : 'instant',
|
||||
})
|
||||
},
|
||||
} as any), // TODO: Better types.
|
||||
[getScrollableNode],
|
||||
[],
|
||||
)
|
||||
|
||||
// --- onContentSizeChange, maintainVisibleContentPosition ---
|
||||
// --- onContentSizeChange ---
|
||||
const containerRef = useRef(null)
|
||||
useResizeObserver(containerRef, onContentSizeChange)
|
||||
|
||||
// --- onScroll ---
|
||||
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
|
||||
const handleScroll = useNonReactiveCallback(() => {
|
||||
if (!isInsideVisibleTree) return
|
||||
|
||||
const element = getScrollableNode()
|
||||
contextScrollHandlers.onScroll?.(
|
||||
{
|
||||
contentOffset: {
|
||||
x: Math.max(0, element?.scrollX ?? 0),
|
||||
y: Math.max(0, element?.scrollY ?? 0),
|
||||
},
|
||||
layoutMeasurement: {
|
||||
width: element?.clientWidth,
|
||||
height: element?.clientHeight,
|
||||
},
|
||||
contentSize: {
|
||||
width: element?.scrollWidth,
|
||||
height: element?.scrollHeight,
|
||||
},
|
||||
} as Exclude<
|
||||
ReanimatedScrollEvent,
|
||||
| 'velocity'
|
||||
| 'eventName'
|
||||
| 'zoomScale'
|
||||
| 'targetContentOffset'
|
||||
| 'contentInset'
|
||||
>,
|
||||
null as any,
|
||||
)
|
||||
const handleWindowScroll = useNonReactiveCallback(() => {
|
||||
if (isInsideVisibleTree) {
|
||||
contextScrollHandlers.onScroll?.(
|
||||
{
|
||||
contentOffset: {
|
||||
x: Math.max(0, window.scrollX),
|
||||
y: Math.max(0, window.scrollY),
|
||||
},
|
||||
} as any, // TODO: Better types.
|
||||
null as any,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isInsideVisibleTree) {
|
||||
// Prevents hidden tabs from firing scroll events.
|
||||
// Only one list is expected to be firing these at a time.
|
||||
return
|
||||
}
|
||||
|
||||
const element = getScrollableNode()
|
||||
|
||||
element?.addEventListener('scroll', handleScroll)
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => {
|
||||
element?.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('scroll', handleWindowScroll)
|
||||
}
|
||||
}, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
|
||||
}, [isInsideVisibleTree, handleWindowScroll])
|
||||
|
||||
// --- onScrolledDownChange ---
|
||||
const isScrolledDown = useRef(false)
|
||||
@@ -232,17 +148,6 @@ function ListImpl<ItemT>(
|
||||
}
|
||||
}
|
||||
|
||||
// --- onStartReached ---
|
||||
const onHeadVisibilityChange = useNonReactiveCallback(
|
||||
(isHeadVisible: boolean) => {
|
||||
if (isHeadVisible) {
|
||||
onStartReached?.({
|
||||
distanceFromStart: onStartReachedThreshold || 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// --- onEndReached ---
|
||||
const onTailVisibilityChange = useNonReactiveCallback(
|
||||
(isTailVisible: boolean) => {
|
||||
@@ -255,11 +160,7 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
// @ts-ignore web only
|
||||
style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]}
|
||||
ref={nativeRef}>
|
||||
<View {...props} style={style} ref={nativeRef}>
|
||||
<Visibility
|
||||
onVisibleChange={setIsInsideVisibleTree}
|
||||
style={
|
||||
@@ -277,17 +178,9 @@ function ListImpl<ItemT>(
|
||||
pal.border,
|
||||
]}>
|
||||
<Visibility
|
||||
root={containWeb ? nativeRef.current : null}
|
||||
onVisibleChange={handleAboveTheFoldVisibleChange}
|
||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||
/>
|
||||
{onStartReached && (
|
||||
<Visibility
|
||||
root={containWeb ? nativeRef.current : null}
|
||||
onVisibleChange={onHeadVisibilityChange}
|
||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||
/>
|
||||
)}
|
||||
{header}
|
||||
{(data as Array<ItemT>).map((item, index) => (
|
||||
<Row<ItemT>
|
||||
@@ -300,9 +193,8 @@ function ListImpl<ItemT>(
|
||||
))}
|
||||
{onEndReached && (
|
||||
<Visibility
|
||||
root={containWeb ? nativeRef.current : null}
|
||||
topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||
onVisibleChange={onTailVisibilityChange}
|
||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||
/>
|
||||
)}
|
||||
{footer}
|
||||
@@ -363,15 +255,11 @@ let Row = function RowImpl<ItemT>({
|
||||
Row = React.memo(Row)
|
||||
|
||||
let Visibility = ({
|
||||
root = null,
|
||||
topMargin = '0px',
|
||||
bottomMargin = '0px',
|
||||
onVisibleChange,
|
||||
style,
|
||||
}: {
|
||||
root?: Element | null
|
||||
topMargin?: string
|
||||
bottomMargin?: string
|
||||
onVisibleChange: (isVisible: boolean) => void
|
||||
style?: ViewProps['style']
|
||||
}): React.ReactNode => {
|
||||
@@ -393,15 +281,14 @@ let Visibility = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(handleIntersection, {
|
||||
root,
|
||||
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
|
||||
rootMargin: `${topMargin} 0px 0px 0px`,
|
||||
})
|
||||
const tail: Element | null = tailRef.current!
|
||||
observer.observe(tail)
|
||||
return () => {
|
||||
observer.unobserve(tail)
|
||||
}
|
||||
}, [bottomMargin, handleIntersection, topMargin, root])
|
||||
}, [handleIntersection, topMargin])
|
||||
|
||||
return (
|
||||
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
|
||||
|
||||
@@ -2,11 +2,19 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
|
||||
export const FlatList_INTERNAL = Animated.FlatList
|
||||
export function CenteredView(props) {
|
||||
return <View {...props} />
|
||||
}
|
||||
|
||||
export function ScrollView(props) {
|
||||
return <Animated.ScrollView {...props} />
|
||||
const gate = useGate()
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
{...props}
|
||||
showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {memo} from 'react'
|
||||
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
@@ -160,7 +160,7 @@ let PostDropdownBtn = ({
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
const str = richTextToString(richText, true)
|
||||
|
||||
Clipboard.setStringAsync(str)
|
||||
setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, richText])
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, {ComponentProps, FC} from 'react'
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedImages} from '@atproto/api'
|
||||
import React, {ComponentProps, FC} from 'react'
|
||||
import {StyleSheet, Text, Pressable, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
type EventFunction = (index: number) => void
|
||||
|
||||
@@ -20,11 +20,9 @@ import {Text} from '../text/Text'
|
||||
export const ExternalLinkEmbed = ({
|
||||
link,
|
||||
style,
|
||||
hideAlt,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
style?: StyleProp<ViewStyle>
|
||||
hideAlt?: boolean
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -39,7 +37,7 @@ export const ExternalLinkEmbed = ({
|
||||
}, [link.uri, externalEmbedPrefs])
|
||||
|
||||
if (embedPlayerParams?.source === 'tenor') {
|
||||
return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
|
||||
return <GifEmbed params={embedPlayerParams} link={link} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {EmbedPlayerParams} from 'lib/strings/embed-player'
|
||||
import {useAutoplayDisabled} from 'state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||
|
||||
@@ -86,11 +82,9 @@ function PlaybackControls({
|
||||
export function GifEmbed({
|
||||
params,
|
||||
link,
|
||||
hideAlt,
|
||||
}: {
|
||||
params: EmbedPlayerParams
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
hideAlt?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
@@ -117,8 +111,7 @@ export function GifEmbed({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
|
||||
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
@@ -140,69 +133,9 @@ export function GifEmbed({
|
||||
onPlayerStateChange={onPlayerStateChange}
|
||||
ref={playerRef}
|
||||
accessibilityHint={_(msg`Animated GIF`)}
|
||||
accessibilityLabel={link.description.replace('Alt text: ', '')}
|
||||
accessibilityLabel={link.description.replace('ALT: ', '')}
|
||||
/>
|
||||
|
||||
{!hideAlt && link.description.startsWith('Alt text: ') && (
|
||||
<AltText text={link.description.replace('Alt text: ', '')} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AltText({text}: {text: string}) {
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Show alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={control.open}
|
||||
style={styles.altContainer}>
|
||||
<Text style={styles.alt} accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Alt Text</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>{text}</Prompt.DescriptionText>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
onPress={control.close}
|
||||
cta={_(msg`Close`)}
|
||||
color="secondary"
|
||||
/>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
// Related to margin/gap hack. This keeps the alt label in the same position
|
||||
// on all platforms
|
||||
left: isWeb ? 8 : 5,
|
||||
bottom: isWeb ? 8 : 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
alt: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
+14
-16
@@ -119,24 +119,22 @@ function HomeScreenReady({
|
||||
const gate = useGate()
|
||||
const mode = useMinimalShellMode()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const listener = AppState.addEventListener('change', nextAppState => {
|
||||
if (nextAppState === 'active') {
|
||||
if (
|
||||
isMobile &&
|
||||
mode.value === 1 &&
|
||||
gate('disable_min_shell_on_foregrounding_v3')
|
||||
) {
|
||||
setMinimalShellMode(false)
|
||||
}
|
||||
React.useEffect(() => {
|
||||
const listener = AppState.addEventListener('change', nextAppState => {
|
||||
if (nextAppState === 'active') {
|
||||
if (
|
||||
isMobile &&
|
||||
mode.value === 1 &&
|
||||
gate('disable_min_shell_on_foregrounding_v2')
|
||||
) {
|
||||
setMinimalShellMode(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
listener.remove()
|
||||
}
|
||||
}, [setMinimalShellMode, mode, isMobile, gate]),
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
listener.remove()
|
||||
}
|
||||
}, [setMinimalShellMode, mode, isMobile, gate])
|
||||
|
||||
const onPageSelected = React.useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
@@ -36,6 +38,7 @@ export function ModerationBlockedAccounts({}: Props) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {screen} = useAnalytics()
|
||||
const gate = useGate()
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
@@ -165,6 +168,9 @@ export function ModerationBlockedAccounts({}: Props) {
|
||||
)}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
showsVerticalScrollIndicator={
|
||||
isWeb || !gate('hide_vertical_scroll_indicators')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CenteredView>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
@@ -36,6 +38,7 @@ export function ModerationMutedAccounts({}: Props) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {screen} = useAnalytics()
|
||||
const gate = useGate()
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
@@ -164,6 +167,9 @@ export function ModerationMutedAccounts({}: Props) {
|
||||
)}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
showsVerticalScrollIndicator={
|
||||
isWeb || !gate('hide_vertical_scroll_indicators')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CenteredView>
|
||||
|
||||
@@ -454,29 +454,33 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (isCurateList && (isBlocking || isMuting)) {
|
||||
if (isCurateList) {
|
||||
items.push({label: 'separator'})
|
||||
|
||||
if (isMuting) {
|
||||
if (!isBlocking) {
|
||||
items.push({
|
||||
testID: 'listHeaderDropdownMuteBtn',
|
||||
label: _(msg`Un-mute list`),
|
||||
onPress: onUnsubscribeMute,
|
||||
label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
|
||||
onPress: isMuting
|
||||
? onUnsubscribeMute
|
||||
: subscribeMutePromptControl.open,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'eye',
|
||||
name: isMuting ? 'eye' : 'eye.slash',
|
||||
},
|
||||
android: '',
|
||||
web: 'eye',
|
||||
web: isMuting ? 'eye' : ['far', 'eye-slash'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isBlocking) {
|
||||
if (!isMuting) {
|
||||
items.push({
|
||||
testID: 'listHeaderDropdownBlockBtn',
|
||||
label: _(msg`Un-block list`),
|
||||
onPress: onUnsubscribeBlock,
|
||||
label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
|
||||
onPress: isBlocking
|
||||
? onUnsubscribeBlock
|
||||
: subscribeBlockPromptControl.open,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'person.fill.xmark',
|
||||
@@ -504,7 +508,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
|
||||
isBlocking,
|
||||
isMuting,
|
||||
onUnsubscribeMute,
|
||||
subscribeMutePromptControl.open,
|
||||
onUnsubscribeBlock,
|
||||
subscribeBlockPromptControl.open,
|
||||
])
|
||||
|
||||
const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {appVersion, BUNDLE_DATE, bundleInfo} from 'lib/app-info'
|
||||
import * as AppInfo from 'lib/app-info'
|
||||
import {STATUS_PAGE_URL} from 'lib/constants'
|
||||
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
|
||||
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
|
||||
@@ -49,6 +51,7 @@ import {HandIcon, HashtagIcon} from 'lib/icons'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
||||
@@ -59,40 +62,25 @@ import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {ScrollView} from 'view/com/util/Views'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {navigate, resetToTab} from '#/Navigation'
|
||||
import {Email2FAToggle} from './Email2FAToggle'
|
||||
import {ExportCarDialog} from './ExportCarDialog'
|
||||
|
||||
function SettingsAccountCard({
|
||||
account,
|
||||
pendingDid,
|
||||
onPressSwitchAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
pendingDid: string | null
|
||||
onPressSwitchAccount: (
|
||||
account: SessionAccount,
|
||||
logContext: 'Settings',
|
||||
) => void
|
||||
}) {
|
||||
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {isSwitchingAccounts, currentAccount} = useSession()
|
||||
const {logout} = useSessionApi()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
const isCurrentAccount = account.did === currentAccount?.did
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
|
||||
const contents = (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
styles.linkCard,
|
||||
account.did === pendingDid && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[pal.view, styles.linkCard]}>
|
||||
<View style={styles.avi}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
@@ -124,8 +112,7 @@ function SettingsAccountCard({
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign out`)}
|
||||
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
|
||||
activeOpacity={0.8}>
|
||||
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
<Trans>Sign out</Trans>
|
||||
</Text>
|
||||
@@ -151,12 +138,13 @@ function SettingsAccountCard({
|
||||
testID={`switchToAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
onPress={
|
||||
pendingDid ? undefined : () => onPressSwitchAccount(account, 'Settings')
|
||||
isSwitchingAccounts
|
||||
? undefined
|
||||
: () => onPressSwitchAccount(account, 'Settings')
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Switch to ${account.handle}`)}
|
||||
accessibilityHint={_(msg`Switches the account you are logged in to`)}
|
||||
activeOpacity={0.8}>
|
||||
accessibilityHint={_(msg`Switches the account you are logged in to`)}>
|
||||
{contents}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
@@ -177,14 +165,17 @@ export function SettingsScreen({}: Props) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {screen, track} = useAnalytics()
|
||||
const {openModal} = useModalControls()
|
||||
const {accounts, currentAccount} = useSession()
|
||||
const {isSwitchingAccounts, accounts, currentAccount} = useSession()
|
||||
const {mutate: clearPreferences} = useClearPreferencesMutation()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const exportCarControl = useDialogControl()
|
||||
const birthdayControl = useDialogControl()
|
||||
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
|
||||
const isSwitchingAccounts = !!pendingDid
|
||||
|
||||
// TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED
|
||||
const gate = useGate()
|
||||
const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} =
|
||||
useDmServiceUrlStorage()
|
||||
|
||||
// const primaryBg = useCustomPalette<ViewStyle>({
|
||||
// light: {backgroundColor: colors.blue0},
|
||||
@@ -255,7 +246,7 @@ export function SettingsScreen({}: Props) {
|
||||
|
||||
const onPressBuildInfo = React.useCallback(() => {
|
||||
setStringAsync(
|
||||
`Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`,
|
||||
`Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
|
||||
)
|
||||
Toast.show(_(msg`Copied build version to clipboard`))
|
||||
}, [_])
|
||||
@@ -292,6 +283,10 @@ export function SettingsScreen({}: Props) {
|
||||
navigation.navigate('AccessibilitySettings')
|
||||
}, [navigation])
|
||||
|
||||
const onPressStatusPage = React.useCallback(() => {
|
||||
Linking.openURL(STATUS_PAGE_URL)
|
||||
}, [])
|
||||
|
||||
const onPressBirthday = React.useCallback(() => {
|
||||
birthdayControl.open()
|
||||
}, [birthdayControl])
|
||||
@@ -376,54 +371,51 @@ export function SettingsScreen({}: Props) {
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
{!currentAccount.emailConfirmed && <EmailConfirmationNotice />}
|
||||
|
||||
<View style={[s.flexRow, styles.heading]}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
<Trans>Signed in as</Trans>
|
||||
</Text>
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
<View pointerEvents={pendingDid ? 'none' : 'auto'}>
|
||||
<SettingsAccountCard
|
||||
account={currentAccount}
|
||||
onPressSwitchAccount={onPressSwitchAccount}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View pointerEvents={pendingDid ? 'none' : 'auto'}>
|
||||
{accounts
|
||||
.filter(a => a.did !== currentAccount?.did)
|
||||
.map(account => (
|
||||
<SettingsAccountCard
|
||||
key={account.did}
|
||||
account={account}
|
||||
onPressSwitchAccount={onPressSwitchAccount}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
testID="switchToNewAccountBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add account`)}
|
||||
accessibilityHint={_(msg`Create a new Bluesky account`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Add account</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={[s.flexRow, styles.heading]}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
<Trans>Signed in as</Trans>
|
||||
</Text>
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
|
||||
{isSwitchingAccounts ? (
|
||||
<View style={[pal.view, styles.linkCard]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<SettingsAccountCard account={currentAccount!} />
|
||||
)}
|
||||
|
||||
{accounts
|
||||
.filter(a => a.did !== currentAccount?.did)
|
||||
.map(account => (
|
||||
<SettingsAccountCard key={account.did} account={account} />
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
testID="switchToNewAccountBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add account`)}
|
||||
accessibilityHint={_(msg`Create a new Bluesky account`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Add account</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
@@ -794,6 +786,22 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>System log</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{gate('dms') && (
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
value={dmServiceUrl}
|
||||
onChangeText={(text: string) => {
|
||||
if (text.length > 9 && text.endsWith('/')) {
|
||||
text = text.slice(0, -1)
|
||||
}
|
||||
setDmServiceUrl(text)
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
label="🐴"
|
||||
/>
|
||||
</TextField.Root>
|
||||
)}
|
||||
{__DEV__ ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
@@ -865,9 +873,17 @@ export function SettingsScreen({}: Props) {
|
||||
accessibilityRole="button"
|
||||
onPress={onPressBuildInfo}>
|
||||
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
|
||||
<Trans>
|
||||
Version {appVersion} {bundleInfo}
|
||||
</Trans>
|
||||
<Trans>Version {AppInfo.appVersion}</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text type="sm" style={[pal.textLight]}>
|
||||
·
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={onPressStatusPage}>
|
||||
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
|
||||
<Trans>Status page</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -889,12 +905,6 @@ export function SettingsScreen({}: Props) {
|
||||
href="https://bsky.social/about/support/privacy-policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
/>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
href={STATUS_PAGE_URL}
|
||||
text={_(msg`Status Page`)}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.footerSpacer} />
|
||||
</ScrollView>
|
||||
@@ -1040,6 +1050,7 @@ const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 18,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react'
|
||||
import {FlatList, View} from 'react-native'
|
||||
|
||||
import {ScrollProvider} from 'lib/ScrollContext'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListContained() {
|
||||
const [animated, setAnimated] = React.useState(false)
|
||||
const ref = React.useRef<FlatList>(null)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
return Array.from({length: 100}, (_, i) => ({
|
||||
id: i,
|
||||
text: `Message ${i}`,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{width: '100%', height: 300}}>
|
||||
<ScrollProvider
|
||||
onScroll={() => {
|
||||
'worklet'
|
||||
console.log('onScroll')
|
||||
}}>
|
||||
<List
|
||||
data={data}
|
||||
renderItem={item => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<Text>{item.item.text}</Text>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
containWeb={true}
|
||||
style={{flex: 1}}
|
||||
onStartReached={() => {
|
||||
console.log('Start Reached')
|
||||
}}
|
||||
onEndReached={() => {
|
||||
console.log('End Reached (threshold of 2)')
|
||||
}}
|
||||
onEndReachedThreshold={2}
|
||||
ref={ref}
|
||||
disableVirtualization={true}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}>
|
||||
<Toggle.Item
|
||||
name="a"
|
||||
label="Click me"
|
||||
value={animated}
|
||||
onChange={() => setAnimated(prev => !prev)}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText>Animated Scrolling</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label="Scroll to End"
|
||||
onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}>
|
||||
<ButtonText>Scroll to Top</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label="Scroll to End"
|
||||
onPress={() => ref.current?.scrollToEnd({animated})}>
|
||||
<ButtonText>Scroll to End</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label="Scroll to Offset 100"
|
||||
onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}>
|
||||
<ButtonText>Scroll to Offset 500</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useSetThemePrefs} from '#/state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ListContained} from 'view/screens/Storybook/ListContained'
|
||||
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Breakpoints} from './Breakpoints'
|
||||
@@ -20,111 +18,77 @@ import {Theming} from './Theming'
|
||||
import {Typography} from './Typography'
|
||||
|
||||
export function Storybook() {
|
||||
if (isWeb) return <StorybookInner />
|
||||
const t = useTheme()
|
||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<StorybookInner />
|
||||
<CenteredView style={[t.atoms.bg]}>
|
||||
<View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
|
||||
<View style={[a.flex_row, a.align_start, a.gap_md]}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="small"
|
||||
label='Set theme to "system"'
|
||||
onPress={() => setColorMode('system')}>
|
||||
<ButtonText>System</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "light"'
|
||||
onPress={() => setColorMode('light')}>
|
||||
<ButtonText>Light</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "dim"'
|
||||
onPress={() => {
|
||||
setColorMode('dark')
|
||||
setDarkTheme('dim')
|
||||
}}>
|
||||
<ButtonText>Dim</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "dark"'
|
||||
onPress={() => {
|
||||
setColorMode('dark')
|
||||
setDarkTheme('dark')
|
||||
}}>
|
||||
<ButtonText>Dark</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Dialogs />
|
||||
<ThemeProvider theme="light">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
<ThemeProvider theme="dim">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
<ThemeProvider theme="dark">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
|
||||
<Typography />
|
||||
<Spacing />
|
||||
<Shadows />
|
||||
<Buttons />
|
||||
<Icons />
|
||||
<Links />
|
||||
<Forms />
|
||||
<Dialogs />
|
||||
<Menus />
|
||||
<Breakpoints />
|
||||
</View>
|
||||
</CenteredView>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function StorybookInner() {
|
||||
const t = useTheme()
|
||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||
const [showContainedList, setShowContainedList] = React.useState(false)
|
||||
|
||||
return (
|
||||
<CenteredView style={[t.atoms.bg]}>
|
||||
<View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
|
||||
{!showContainedList ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_start, a.gap_md]}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="small"
|
||||
label='Set theme to "system"'
|
||||
onPress={() => setColorMode('system')}>
|
||||
<ButtonText>System</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "light"'
|
||||
onPress={() => setColorMode('light')}>
|
||||
<ButtonText>Light</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "dim"'
|
||||
onPress={() => {
|
||||
setColorMode('dark')
|
||||
setDarkTheme('dim')
|
||||
}}>
|
||||
<ButtonText>Dim</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label='Set theme to "dark"'
|
||||
onPress={() => {
|
||||
setColorMode('dark')
|
||||
setDarkTheme('dark')
|
||||
}}>
|
||||
<ButtonText>Dark</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Dialogs />
|
||||
<ThemeProvider theme="light">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
<ThemeProvider theme="dim">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
<ThemeProvider theme="dark">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
|
||||
<Typography />
|
||||
<Spacing />
|
||||
<Shadows />
|
||||
<Buttons />
|
||||
<Icons />
|
||||
<Links />
|
||||
<Forms />
|
||||
<Dialogs />
|
||||
<Menus />
|
||||
<Breakpoints />
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label="Switch to Contained List"
|
||||
onPress={() => setShowContainedList(true)}>
|
||||
<ButtonText>Switch to Contained List</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label="Switch to Storybook"
|
||||
onPress={() => setShowContainedList(false)}>
|
||||
<ButtonText>Switch to Storybook</ButtonText>
|
||||
</Button>
|
||||
<ListContained />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user