Compare commits

..

49 Commits

Author SHA1 Message Date
Eric Bailey d6c5044c1d format 2024-05-06 15:34:51 -05:00
Eric Bailey c35c840c3d Remove unused error type 2024-05-06 15:05:11 -05:00
Eric Bailey 9054ba6979 Handle failed polling 2024-05-06 15:05:11 -05:00
Eric Bailey 6903b68557 Handle two common errors, provide more clarity around error states 2024-05-06 15:05:10 -05:00
Hailey b34d224207 remove hide_vertical_scrollbars test (#3855) 2024-05-06 20:59:36 +01:00
Hailey db0b030359 show spinner when preferences has not loaded yet (#3756) 2024-05-06 20:58:03 +01:00
Samuel Newman c33c3b7d1e Alt text for gifs (#3876)
* add alt text dialog

* multiline alt text input

* add pressable alt text badge

* rename `ALT: ` to `Alt text: ` to avoid including old bad ones

* reuse alt text reminder

* reuse alt text reminder in gallery

* add alt text reminder in the dialog itself

* autofocus text input

* reorder components to fix tab order

* fix close btn position
2024-05-06 17:28:38 +01:00
Hailey ae7626ce6e [🐴] Finalize web message screen (#3868)
* add `onStartReached` to web list

* fix `rootMargin`

* Add `contain`, handle scroll events

* improve types, fix typo

* simplify

* adjust `scrollToTop` and `scrollToOffset` to support `contain`, add `scrollToEnd`

* rename `handleWindowScroll` to `handleScroll`

* support basic `maintainVisibleContentPosition`

* rename `contain` to `containWeb`

* remove unnecessary `flex: 1`

* add missing props

* add root prop to `Visibility`

* add root prop to `Visibility`

* revert adding `maintainVisibleContentPosition`

* remove unnecessary wrapper

* add style

* oops

* maintain position for web

* always apply `flex: 1` to styles when contained

* add a contained list to storybook

* make `onScroll` a worklet in storybook

* revert test code

* remove unnecessary `flex: 1`
2024-05-06 08:48:08 -07:00
Hailey bc07019911 Further align web List with FlatList, add contain mode to web list implementation (#3867)
* add `onStartReached` to web list

* fix `rootMargin`

* Add `contain`, handle scroll events

* improve types, fix typo

* simplify

* adjust `scrollToTop` and `scrollToOffset` to support `contain`, add `scrollToEnd`

* rename `handleWindowScroll` to `handleScroll`

* support basic `maintainVisibleContentPosition`

* rename `contain` to `containWeb`

* remove unnecessary `flex: 1`

* add missing props

* add root prop to `Visibility`

* add root prop to `Visibility`

* revert adding `maintainVisibleContentPosition`

* oops

* always apply `flex: 1` to styles when contained

* add a contained list to storybook

* make `onScroll` a worklet in storybook

* revert test code

* add scrolling to storybook

* simplify getting scrollable node

* nit: extra whitespace

* nit: random comment

* foolproof the logic

* typecheck
2024-05-06 08:34:32 -07:00
Hailey 594b40c3ae Fix IntersectionObserver rootMargin in web List implementation, add onStartReached (#3866)
* add `onStartReached` to web list

* fix `rootMargin`
2024-05-05 04:24:01 -07:00
Hailey 6b615f3720 only use KeyboardProvider in conversation screen (#3869) 2024-05-05 03:14:14 -07:00
Hailey c4160c25a3 [Clipclops] Delete message in dialog (#3849)
* delete and copy

* add retry dialog if message send fails

* add layout animation

* fix `nextMessage` being incorrect

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-05 02:06:10 +01:00
Samuel Newman 7448c8f785 [🐴] Improve message pressing (#3864)
* allow tapping message when keyboard open

* dismiss keyboard on open msg menu

* show message info on press

* Revert "show message info on press"

This reverts commit 61d039a702.
2024-05-05 01:13:57 +01:00
Hailey 91f8202549 fix typo in app-info (#3863) 2024-05-04 16:45:59 -07:00
Samuel Newman 9b7818d358 [🐴] Dismiss keyboard before opening convo menu (#3862)
* Dismiss keyboard before opening convo menu

* call `props.onPress()` instead
2024-05-05 00:39:52 +01:00
Hailey 4862bc2ba8 add bundle identifiers to app-info (#3861)
* add bundle identifiers to `app-info`

* add them to the `.env.example`

* add environment variables for docker build

* add environment variables for native builds and bundles

* also include the hour in bundle date

* organize app info better in settings
2024-05-04 16:33:10 -07:00
Hailey eb55bdf172 [🐴] List Adjustments (#3857) 2024-05-04 13:22:14 -07:00
Samuel Newman c223bcdaf7 Assorted clipclop fixes (#3853)
* empty state for new chat dialog

* use terniary

* dark mode pending state

* copy message text option

* fix service url input (scrollview ftw)

* whoops, fix equality

* slightly reduce horizontal message padding
2024-05-04 01:49:49 +01:00
Hailey feff55a14a Clipclop tweaks (#3851)
* scroll on input resize

* add back the padding 🙊

* fix timestamps
2024-05-03 15:08:59 -07:00
Hailey 876816675e [Clipclops] Refactor message list (#3832)
* rework the list for accessibility

* Reverse reverse

* progress

* good to start testing

* memo `MessageItem`

* small hack

* use our custom `List` impl

* use `ScrollProvider` for `onScroll` event

* remove use of `runOnJS`

* actually, let's keep it

* add some comments

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-03 14:18:01 -07:00
Samuel Newman 6a4199febb [Clipclops] Pending message style with layout animation (#3844)
* decrease group gap to 3 mins

* pending style with layout animation

* make pending state lighter
2024-05-03 20:19:48 +01:00
Samuel Newman 55f3df5596 reenable tabs (#3847) 2024-05-03 20:14:10 +01:00
Hailey 19a6bdcc86 Version bump (#3848) 2024-05-03 11:21:02 -07:00
Samuel Newman ce02a41163 add focus refresh + polling (#3846) 2024-05-03 17:57:20 +01:00
dan 4a2d4253e5 [Session] Align state and global agent switchpoints (#3845)
* Adopt synced accounts unconditionally

* Remove try/catch around resuming session

* Move to login form on resume failure

* Restructure code flow for easier reading

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-03 17:57:09 +01:00
dan 85b34418ef Don't clear toasts when changing users (#3843)
* Move ThemeProvider to the top

* Move RootSiblingParent above the remounted part

* Move ToastContainer outside the remounted part

* Remove setTimeout around toasts

* Consistently handle dropped session
2024-05-03 16:36:58 +01:00
Samuel Newman aeed4786db make service url gate friendlier (#3841) 2024-05-03 16:32:20 +01:00
Samuel Newman 67b0cdf649 remove broken keyboard offset code (#3842) 2024-05-03 16:14:25 +01:00
Samuel Newman 997e621ce3 [Clipclops] Header tweaks (#3839)
* wrap text

* add dividers to convo menu
2024-05-03 15:55:35 +01:00
Samuel Newman a5511e3c22 update date logic to account for timezones (#3840) 2024-05-03 15:55:24 +01:00
Eric Bailey 051e897a2b [Clipclops] Moar error (#3837)
* Add history error

* Log error

* Add period
2024-05-03 09:39:40 -05:00
dan 1e484c6318 [Session] Reset global agent on expire (#3838) 2024-05-03 13:46:34 +01:00
dan cdf7a1957a [Session] Rely on agent session change event for persisting resumed session (#3836)
* Rely on agent session change handler for resumption

* Add a fast path for noop resumes
2024-05-03 03:47:11 +01:00
Eric Bailey c9cf608f78 [Clipclops] External store, suspend/resume (#3829)
* Initial working external store

* Clean up WIP, explore suspend/resume

* Clean up state, bindings, snapshots, add some logs

* Reduce snapshots, add better logic check

* Bump interval a smidge

* Remove unused type
2024-05-02 20:57:51 -05:00
dan c13685a0cf [Session] Fix stale emailAuthFactor and emailConfirmed on the client (#3835)
* Fix account refresh to not clobber falsy values

* Use ?? in more places
2024-05-03 02:52:37 +01:00
dan 0f827c3213 [Session] Track agent in state (#3833)
* Expand currentAccountDid into currentAgentState

* Inline all callsites of upsertAccount

* Inline all internal callsites of clearCurrentAccount

* Add agent to currentAgentState

* Collapse extra setStates

* Add TODOs
2024-05-03 02:04:36 +01:00
Samuel Newman a9af83692c visually align icon (#3831) 2024-05-03 01:28:03 +01:00
dan 6405ad7cba [Session] Call persist handler directly (#3828) 2024-05-02 23:05:53 +01:00
dan b86c3b486f Improve account switcher pending state (#3827)
* Protect against races

* Reduce UI jank when switching accounts

* Add pending state to selected account

* Disable presses while pending
2024-05-02 21:55:50 +01:00
Hailey 8ba1b10ce0 [Clipclops] Message actions for native and web (#3807)
* haptic on long press

* add animation to press and hold

* eslint disable for now

* adjust styles

* dont trigger if animation is cancelled

* organize

* add a delete menu

* reset scale automatically

* message actions dialog

cleanup

center the trigger

handle focus/unfocus better

make triggers accessible

weg dropdown menu

add a wep specific wrapper

decrease press delay

add report button

improve shrink logic

use `self_end` instead of `margin: auto`

rm extra `?`

move `MessageItem` to `components`

add delete button

* rm some padding

* update after merge

* fix merge

* web only types

* fix crash

* add an explanation

* fix web types

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-02 13:54:17 -07:00
Samuel Newman 6da18e3dcf Only run "disable min shell on foregrounding" behaviour if on the home screen (#3825)
* change useEffect to useFocusEffect

* v2 -> v3
2024-05-02 21:27:54 +01:00
Samuel Newman 96c5db3e69 create keyboardverticaloffset hook (#3823) 2024-05-02 21:05:11 +01:00
Samuel Newman aca55cb192 rename "MessagesList" to "Messages" (#3826) 2024-05-02 21:02:45 +01:00
Eric Bailey a20fe4c9a0 [Clipclops] Deletions (#3824)
* Handle message deletions

* Handle failures
2024-05-02 13:12:34 -05:00
dan 1a07e23192 [Session] Extract selectAccount out (#3812) 2024-05-02 18:25:09 +01:00
dan 5ec945b762 [Session] Extract resumeSession out (#3811) 2024-05-02 18:17:53 +01:00
Samuel Newman dadf27fd2f [Clipclops] Minor web tweaks (#3822)
* add a11y + onPress to web send button

* only adjust leading on web, due to overflow issue
2024-05-02 18:00:42 +01:00
Samuel Newman bff055f618 [Clipclop] Input polish (#3819)
* tweak input styles + add min-shell mode

* android tweaks + hitslop
2024-05-02 17:08:41 +01:00
Hailey e977c99c96 [Clipclops] rm <Trans> from header (#3818) 2024-05-02 08:54:12 -07:00
63 changed files with 2716 additions and 1216 deletions
+2
View File
@@ -4,3 +4,5 @@ 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,3 +54,6 @@ 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,6 +57,8 @@ 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
+2
View File
@@ -65,6 +65,8 @@ 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,6 +144,8 @@ 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
@@ -222,6 +224,8 @@ 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
@@ -283,6 +287,8 @@ 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
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.80.0",
"version": "1.81.0",
"private": true,
"engines": {
"node": ">=18"
+51 -39
View File
@@ -4,7 +4,6 @@ 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,
@@ -16,6 +15,7 @@ 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,6 +34,7 @@ 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'
@@ -53,8 +54,9 @@ import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
@@ -62,49 +64,61 @@ function InnerApp() {
// init
useEffect(() => {
listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
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)
}, [resumeSession, _])
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<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>
<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>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</Splash>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
)
@@ -142,9 +156,7 @@ function App() {
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<KeyboardProvider>
<InnerApp />
</KeyboardProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
+53 -29
View File
@@ -4,8 +4,11 @@ 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'
@@ -22,64 +25,85 @@ 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 {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = 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)
}, [resumeSession])
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
// wait for session to resume
if (isInitialLoad) return null
if (!isReady) return null
return (
<Alf theme={theme}>
<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>
<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>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</RootSiblingParent>
</ThemeProvider>
</Alf>
)
}
+6 -6
View File
@@ -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 {MessagesListScreen} from './screens/Messages/List'
import {MessagesScreen} 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="MessagesList"
getComponent={() => MessagesListScreen}
name="Messages"
getComponent={() => MessagesScreen}
options={{requireAuth: true}}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="MessagesList"
getComponent={() => MessagesListScreen}
name="Messages"
getComponent={() => MessagesScreen}
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', 'MessagesList', params)
return buildStateObject('MessagesTab', 'Messages', 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, [
+10 -5
View File
@@ -16,12 +16,14 @@ export function AccountList({
onSelectAccount,
onSelectOther,
otherLabel,
pendingDid,
}: {
onSelectAccount: (account: SessionAccount) => void
onSelectOther: () => void
otherLabel?: string
pendingDid: string | null
}) {
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
const {currentAccount, accounts} = useSession()
const t = useTheme()
const {_} = useLingui()
@@ -31,6 +33,7 @@ export function AccountList({
return (
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_md,
a.overflow_hidden,
@@ -43,6 +46,7 @@ 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>
@@ -50,7 +54,7 @@ export function AccountList({
<Button
testID="chooseAddAccountBtn"
style={[a.flex_1]}
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
onPress={pendingDid ? undefined : onPressAddAccount}
label={_(msg`Login to account that is not listed`)}>
{({hovered, pressed}) => (
<View
@@ -59,8 +63,7 @@ export function AccountList({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed || isSwitchingAccounts) &&
t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<Text
style={[
@@ -84,10 +87,12 @@ function AccountItem({
account,
onSelect,
isCurrentAccount,
isPendingAccount,
}: {
account: SessionAccount
onSelect: (account: SessionAccount) => void
isCurrentAccount: boolean
isPendingAccount: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -115,7 +120,7 @@ function AccountItem({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed) && t.atoms.bg_contrast_25,
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
+3 -1
View File
@@ -43,7 +43,9 @@ export function Outer({
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
{children}
</Dialog.ScrollableInner>
</Context.Provider>
+2 -2
View File
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner control={control} />
<MutedWordsInner />
</Dialog.Outer>
)
}
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
function MutedWordsInner() {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
+2 -1
View File
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelectAccount = useCallback(
@@ -54,6 +54,7 @@ export function SwitchAccountDialog({
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</View>
</Dialog.ScrollableInner>
+83
View File
@@ -0,0 +1,83 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable, View} from 'react-native'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {useHaptics} from 'lib/haptics'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animationDidComplete = useSharedValue(false)
const animatedStyle = useAnimatedStyle(() => ({
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)
scale.value = withTiming(1, {duration: 200}, () => {
animationDidComplete.value = false
})
}, [animationDidComplete, scale])
const grow = React.useCallback(() => {
'worklet'
scale.value = withTiming(1.05, {duration: 750}, finished => {
if (!finished) return
animationDidComplete.value = true
runOnJS(playHaptic)()
runOnJS(open)()
shrink()
})
}, [scale, animationDidComplete, playHaptic, shrink, open])
return (
<View
style={[
{
maxWidth: '65%',
},
isFromSelf ? a.self_end : a.self_start,
]}>
<AnimatedPressable
style={animatedStyle}
unstable_pressDelay={200}
onPressIn={grow}
onTouchEnd={shrink}>
{children}
</AnimatedPressable>
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
</View>
)
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const [showActions, setShowActions] = React.useState(false)
const onMouseEnter = React.useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = React.useCallback(() => {
setShowActions(false)
}, [])
// 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 => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
return (
<View
// @ts-expect-error web only
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_xl,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
<View
style={{
maxWidth: '65%',
}}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
</View>
)
}
+9 -2
View File
@@ -1,5 +1,5 @@
import React, {useCallback} from 'react'
import {Pressable} from 'react-native'
import {Keyboard, 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('MessagesList')
navigation.replace('Messages')
}
},
onError: () => {
@@ -88,6 +88,11 @@ 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,
@@ -123,6 +128,7 @@ let ConvoMenu = ({
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
{/* TODO(samuel): implement these */}
<Menu.Group>
<Menu.Item
@@ -146,6 +152,7 @@ let ConvoMenu = ({
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
@@ -1,24 +1,27 @@
import React, {useCallback, useMemo} from 'react'
import {StyleProp, TextStyle, View} from 'react-native'
import React, {useCallback, useMemo, useRef} from 'react'
import {LayoutAnimation, StyleProp, TextStyle, 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 {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography'
export function MessageItem({
export let MessageItem = ({
item,
next,
pending,
}: {
item: ChatBskyConvoDefs.MessageView
next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}) {
pending?: boolean
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
@@ -29,55 +32,73 @@ export function 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 10 minute gap between this message and the next
// or, if there's a 3 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()
// 10 minutes
return diff > 10 * 60 * 1000
// 3 minutes
return diff > 3 * 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>
<View
style={[
a.py_sm,
a.px_lg,
a.my_2xs,
a.rounded_md,
isFromSelf ? a.self_end : a.self_start,
{
maxWidth: '65%',
backgroundColor: isFromSelf
? t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? pending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
{item.text}
</Text>
</View>
<Metadata
<Text
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
pending && t.name !== 'light' && {color: t.palette.primary_300},
]}>
{item.text}
</Text>
</View>
</ActionsWrapper>
<MessageItemMetadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
@@ -86,7 +107,9 @@ export function MessageItem({
)
}
function Metadata({
MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message,
isLastInGroup,
style,
@@ -94,7 +117,7 @@ function Metadata({
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}) {
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
@@ -117,16 +140,15 @@ function Metadata({
}
// if in the last day
if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
if (localDateString(now) === localDateString(date)) {
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)
) {
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
@@ -153,7 +175,7 @@ function Metadata({
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_xs,
a.mt_2xs,
a.mb_lg,
style,
]}>
@@ -163,3 +185,14 @@ function Metadata({
</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}`
}
+141
View File
@@ -0,0 +1,141 @@
import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
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'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
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,
control,
hideTrigger,
triggerOpacity,
}: {
hideTrigger?: boolean
triggerOpacity?: number
onTriggerPress?: () => void
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
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])
const onReport = React.useCallback(() => {
// TODO report the message
}, [])
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="sm" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<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`)}
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={onReport}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Delete`)}
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}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
+32 -15
View File
@@ -20,6 +20,7 @@ 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'
@@ -178,7 +179,7 @@ function SearchablePeopleList({
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
<Dialog.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
@@ -197,6 +198,7 @@ function SearchablePeopleList({
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextField.Root>
</View>
@@ -211,20 +213,35 @@ function SearchablePeopleList({
ListHeaderComponent={
<>
{listHeader}
{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.`)
}
/>
{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.`)
}
/>
)
)}
</>
}
+17
View File
@@ -0,0 +1,17 @@
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]),
)
}
+13 -3
View File
@@ -4,7 +4,17 @@ 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'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
IS_DEV ? 'development' : UPDATES_CHANNEL
// 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'
})`
+15
View File
@@ -1,2 +1,17 @@
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'})`
+16 -13
View File
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,12 +8,14 @@ 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 {selectAccount, clearCurrentAccount} = useSessionApi()
const {initSession} = useSessionApi()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
@@ -22,8 +24,12 @@ 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.
@@ -33,10 +39,9 @@ export function useAccountSwitcher() {
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
await selectAccount(account, logContext)
setTimeout(() => {
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
await initSession(account)
logEvent('account:loggedIn', {logContext, withPassword: false})
Toast.show(_(msg`Signed in as @${account.handle}`))
} else {
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
@@ -48,14 +53,12 @@ export function useAccountSwitcher() {
logger.error(`switch account: selectAccount failed`, {
message: e.message,
})
clearCurrentAccount() // back user out to login
setTimeout(() => {
Toast.show(_(msg`Sorry! We need you to enter your password.`))
}, 100)
} finally {
setPendingDid(null)
}
},
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
[_, track, initSession, requestSwitchToAccount, pendingDid],
)
return {onPressSwitchAccount}
return {onPressSwitchAccount, pendingDid}
}
+2 -1
View File
@@ -11,8 +11,9 @@ export function useNavigationTabState() {
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
}
if (
!res.isAtHome &&
!res.isAtSearch &&
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native'
import {getCurrentRoute} from 'lib/routes/helpers'
export function useNavigationTabState() {
@@ -9,6 +10,7 @@ export function useNavigationTabState() {
isAtSearch: currentRoute === 'Search',
isAtNotifications: currentRoute === 'Notifications',
isAtMyProfile: currentRoute === 'MyProfile',
isAtMessages: currentRoute === 'Messages',
}
})
}
+8 -18
View File
@@ -1,11 +1,8 @@
import {BskyAgent} from '@atproto/api'
import {msg} from '@lingui/macro'
import {I18nContext} from '@lingui/react'
import {LINK_META_PROXY} from 'lib/constants'
import {getGiphyMetaUri} from 'lib/strings/embed-player'
import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky'
import {LINK_META_PROXY} from 'lib/constants'
import {getGiphyMetaUri} from 'lib/strings/embed-player'
export enum LikelyType {
HTML,
@@ -29,8 +26,7 @@ export interface LinkMeta {
export async function getLinkMeta(
agent: BskyAgent,
url: string,
timeout: number,
i18n: I18nContext['i18n'],
timeout = 15e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
return extractBskyMeta(agent, url)
@@ -62,11 +58,9 @@ export async function getLinkMeta(
return meta
}
const controller = new AbortController()
const signal = controller.signal
try {
const to = setTimeout(() => controller.abort('TIMEOUT'), timeout || 5e3)
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), timeout || 5e3)
const response = await fetch(
`${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent(
@@ -88,13 +82,9 @@ export async function getLinkMeta(
meta.image = image
meta.title = title
} catch (e) {
if (signal.aborted) {
meta.error = i18n._(
msg`We were unable to fetch a preview for this URL because the request timed out.`,
)
} else {
meta.error = i18n._(msg`We were unable to fetch a preview for this URL.`)
}
// failed
console.error(e)
meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link'
}
return meta
+3 -3
View File
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
MessagesList: undefined
Messages: undefined
}
export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
MessagesList: undefined
Messages: undefined
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
MessagesList: undefined
Messages: undefined
}
// NOTE
+1 -2
View File
@@ -1,10 +1,9 @@
export type Gate =
// Keep this alphabetic please.
| 'autoexpand_suggestions_on_profile_follow_v2'
| 'disable_min_shell_on_foregrounding_v2'
| 'disable_min_shell_on_foregrounding_v3'
| 'disable_poll_on_discover_v2'
| 'dms'
| 'hide_vertical_scroll_indicators'
| 'show_follow_back_label_v2'
| 'start_session_with_following_v2'
| 'test_gate_1'
+1
View File
@@ -9,4 +9,5 @@ export const DebugContext = {
// e.g. composer: 'composer'
session: 'session',
notifications: 'notifications',
convo: 'convo',
} as const
+1 -1
View File
@@ -37,7 +37,7 @@ export const router = new Router({
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
MessagesList: '/messages',
Messages: '/messages',
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
})
+41 -24
View File
@@ -22,6 +22,7 @@ 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()
@@ -34,33 +35,48 @@ export const ChooseAccountForm = ({
const onSelect = React.useCallback(
async (account: SessionAccount) => {
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 {
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.
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, onSelectAccount, setShowLoggedOut, _],
[
currentAccount,
track,
initSession,
pendingDid,
onSelectAccount,
setShowLoggedOut,
_,
],
)
return (
@@ -74,6 +90,7 @@ export const ChooseAccountForm = ({
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
@@ -12,20 +12,21 @@ 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,
onFocus,
onBlur,
scrollToEnd,
}: {
onSendMessage: (message: string) => void
onFocus: () => void
onBlur: () => void
scrollToEnd: () => 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)
@@ -39,11 +40,12 @@ export function MessageInput({
return
}
onSendMessage(message.trimEnd())
playHaptic()
setMessage('')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, onSendMessage])
}, [message, onSendMessage, playHaptic])
const onInputLayout = React.useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
@@ -55,49 +57,55 @@ export function MessageInput({
setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)
scrollToEnd()
},
[topInset],
[scrollToEnd, topInset],
)
return (
<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"
<View style={a.p_sm}>
<View
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} />
</Pressable>
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>
</View>
)
}
@@ -11,8 +11,7 @@ export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
onFocus: () => void
onBlur: () => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
@@ -45,47 +44,51 @@ export function MessageInput({
)
return (
<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.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"
<View style={a.p_sm}>
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<PaperPlane fill={t.palette.white} />
</Pressable>
<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>
</View>
)
}
@@ -0,0 +1,60 @@
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,20 +1,26 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import {
FlatList,
NativeScrollEvent,
NativeSyntheticEvent,
View,
} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
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'
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 {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
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'
import {Text} from '#/components/Typography'
@@ -53,11 +59,19 @@ 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} />
return (
<MessageItem
item={item.message}
next={item.nextMessage}
pending={item.type === 'pending-message'}
/>
)
} 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
@@ -67,100 +81,178 @@ function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToEndFailed() {
function onScrollToIndexFailed() {
// 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)
const onContentSizeChange = useCallback(() => {
if (currentOffset.current <= 100) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
// 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 onEndReached = useCallback(() => {
chat.service.fetchMessageHistory()
}, [chat])
const onInputFocus = useCallback(() => {
if (!isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
}, [])
const onInputBlur = useCallback(() => {}, [])
}, [chat, hasInitiallyScrolled])
const onSendMessage = useCallback(
(text: string) => {
chat.service.sendMessage({
text,
})
if (chat.status === ConvoStatus.Ready) {
chat.sendMessage({
text,
})
}
},
[chat.service],
[chat],
)
const onScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
currentOffset.current = e.nativeEvent.contentOffset.y
(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)
}
},
[],
[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={{flex: 1, marginBottom: isWeb ? 20 : 85}}
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
behavior="padding"
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}
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} />
}
/>
</View>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</KeyboardAvoidingView>
)
}
+32 -35
View File
@@ -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, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -14,7 +14,6 @@ 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'
@@ -43,29 +42,30 @@ export function MessagesConversationScreen({route}: Props) {
function Inner() {
const chat = useChat()
const {currentAccount} = useSession()
const myDid = currentAccount?.did
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.Uninitialized ||
chat.status === ConvoStatus.Initializing
) {
return <ListMaybePlaceholder isLoading />
}
if (chat.status === ConvoStatus.Error) {
// TODO error
return null
}
/*
* Any other chat states (atm) are "ready" states
*/
return (
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={otherProfile} />
<MessagesList />
</CenteredView>
<KeyboardProvider>
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={chat.recipients[0]} />
<MessagesList />
</CenteredView>
</KeyboardProvider>
)
}
@@ -78,22 +78,19 @@ let Header = ({
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const {service} = useChat()
const chat = useChat()
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('MessagesList')
navigation.replace('Messages')
} else {
navigation.pop()
}
}, [navigation])
const onUpdateConvo = useCallback(
(convo: ChatBskyConvoDefs.ConvoView) => {
service.convo = convo
},
[service],
)
const onUpdateConvo = useCallback(() => {
// TODO eric update muted state
}, [])
return (
<View
@@ -129,15 +126,15 @@ let Header = ({
) : (
<View style={{width: 30}} />
)}
<View style={[a.align_center, a.gap_sm]}>
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
<PreviewableUserAvatar size={32} profile={profile} />
<Text style={[a.text_lg, a.font_bold]}>
<Trans>{profile.displayName}</Trans>
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
{profile.displayName}
</Text>
</View>
{service.convo ? (
{chat.status === ConvoStatus.Ready ? (
<ConvoMenu
convo={service.convo}
convo={chat.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
+43 -5
View File
@@ -6,6 +6,7 @@ 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'
@@ -20,11 +21,14 @@ 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 {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme, web} 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'
@@ -32,14 +36,25 @@ 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, 'MessagesList'>
export function MessagesListScreen({navigation}: Props) {
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen({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
@@ -62,7 +77,9 @@ export function MessagesListScreen({navigation}: Props) {
fetchNextPage,
error,
refetch,
} = useListConvos()
} = useListConvos({refetchInterval: 15_000})
useRefreshOnFocus(refetch)
const isError = !!error
@@ -112,6 +129,25 @@ export function MessagesListScreen({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}>
@@ -237,7 +273,9 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
<PreviewableUserAvatar profile={otherUser} size={42} />
</View>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
<Text
numberOfLines={1}
style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
<Text
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
{otherUser.displayName || otherUser.handle}
+14 -6
View File
@@ -1,15 +1,23 @@
import {describe, it} from '@jest/globals'
describe(`#/state/messages/convo`, () => {
describe(`status states`, () => {
describe(`init`, () => {
it.todo(`fails if sender and recipients aren't found`)
it.todo(`cannot re-initialize from a non-unintialized state`)
it.todo(`can re-initialize from a failed state`)
})
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(`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(`history fetching`, () => {
+590 -251
View File
@@ -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,9 +18,21 @@ export type ConvoParams = {
export enum ConvoStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Resuming = 'resuming',
Ready = 'ready',
Error = 'error',
Destroyed = 'destroyed',
Backgrounded = 'backgrounded',
Suspended = 'suspended',
}
export enum ConvoItemError {
HistoryFailed = 'historyFailed',
ResumeFailed = 'resumeFailed',
PollFailed = 'pollFailed',
}
export enum ConvoError {
InitFailed = 'initFailed',
}
export type ConvoItem =
@@ -47,27 +59,109 @@ 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
}
| {
status: ConvoStatus.Destroyed
}
const ACTIVE_POLL_INTERVAL = 2e3
const BACKGROUND_POLL_INTERVAL = 10e3
export function isConvoItemMessage(
item: ConvoItem,
@@ -84,15 +178,19 @@ export class Convo {
private agent: BskyAgent
private __tempFromUserDid: string
private pollInterval = ACTIVE_POLL_INTERVAL
private status: ConvoStatus = ConvoStatus.Uninitialized
private error: any
private error:
| {
code: ConvoError
exception?: Error
retry: () => void
}
| undefined
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined
sender: AppBskyActorDefs.ProfileViewBasic | undefined
private pollingFailure = false
private pastMessages: Map<
string,
@@ -106,25 +204,275 @@ 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)
}
async initialize() {
if (this.status !== 'uninitialized') return
this.status = ConvoStatus.Initializing
private commit() {
this.snapshot = undefined
this.subscribers.forEach(subscriber => subscriber())
}
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()
},
}
this.status = ConvoStatus.Error
this.commit()
}
} else {
logger.warn(`Convo: cannot init from ${this.status}`)
}
}
async resume() {
logger.debug('Convo: resume', {}, logger.DebugContext.convo)
if (
this.status === ConvoStatus.Suspended ||
this.status === ConvoStatus.Backgrounded
) {
const fromStatus = this.status
try {
this.status = ConvoStatus.Resuming
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}`)
}
}
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(
{
convoId: this.convoId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
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,
)
/*
* 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')
}
}
async fetchMessageHistory() {
logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo)
/*
* 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 {
const response = await this.agent.api.chat.bsky.convo.getConvo(
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: {
@@ -132,162 +480,183 @@ export class Convo {
},
},
)
const {convo} = response.data
const {cursor, messages} = response.data
this.convo = convo
this.sender = this.convo.members.find(
m => m.did === this.__tempFromUserDid,
)
this.status = ConvoStatus.Ready
this.historyCursor = cursor ?? null
for (const message of messages) {
if (
ChatBskyConvoDefs.isMessageView(message) ||
ChatBskyConvoDefs.isDeletedMessageView(message)
) {
this.pastMessages.set(message.id, message)
// set to latest rev
if (
message.rev > (this.eventsCursor = 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()
await this.fetchMessageHistory()
this.pollEvents()
} catch (e) {
this.status = ConvoStatus.Error
this.error = e
}
}
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.Ready ||
this.status === ConvoStatus.Backgrounded
) {
if (this.pendingEventIngestion) return
async fetchMessageHistory() {
if (this.status === ConvoStatus.Destroyed) return
// reached end
if (this.historyCursor === null) return
if (this.isFetchingHistory) return
/*
* Represents a failed state, which is retryable.
*/
if (this.pollingFailure) return
this.isFetchingHistory = true
this.commit()
/*
* 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))
setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion
this.pendingEventIngestion = undefined
this.pollEvents()
}, this.pollInterval)
}
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) {
if (
ChatBskyConvoDefs.isMessageView(message) ||
ChatBskyConvoDefs.isDeletedMessageView(message)
) {
this.pastMessages.set(message.id, message)
// set to latest rev
if (
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
) {
this.eventsCursor = message.rev
}
}
}
this.isFetchingHistory = false
this.commit()
}
async ingestLatestEvents() {
if (this.status === ConvoStatus.Destroyed) return
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
try {
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
},
)
const {logs} = response.data
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
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') {
let needsCommit = false
for (const log of logs) {
/*
* We only care about new events
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
if (typeof log.rev === 'string') {
/*
* Update rev regardless of if it's a log type we care about or not
* We only care about new events
*/
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
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)
) {
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
/*
* Update if we have this in state. If we don't, don't worry about it.
* Update rev regardless of if it's a log type we care about or not
*/
if (this.pastMessages.has(log.message.id)) {
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
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)
) {
/*
* 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)
* Update if we have this in state. If we don't, don't worry about it.
*/
this.pastMessages.delete(log.message.id)
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
}
}
}
}
}
}
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()
/*
@@ -343,6 +712,12 @@ export class Convo {
}
async batchRetryPendingMessages() {
logger.debug(
`Convo: retrying ${this.pendingMessages.size} pending messages`,
{},
logger.DebugContext.convo,
)
this.footerItems.delete('pending-retry')
this.commit()
@@ -393,32 +768,43 @@ export class 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
async deleteMessage(messageId: string) {
logger.debug('Convo: delete message', {}, logger.DebugContext.convo)
const tempId = nanoid()
this.pendingMessages.set(tempId, {
id: tempId,
message,
})
this.deletedMessages.add(messageId)
this.commit()
if (!this.isProcessingPendingMessages) {
this.processPendingMessages()
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
}
}
/*
* Items in reverse order, since FlatList inverts
*/
get items(): ConvoItem[] {
getItems(): ConvoItem[] {
const items: ConvoItem[] = []
// `newMessages` is in insertion order, unshift to reverse
this.newMessages.forEach(m => {
this.headerItems.forEach(item => {
items.push(item)
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.unshift({
type: 'message',
@@ -436,9 +822,26 @@ export class Convo {
}
})
// `newMessages` is in insertion order, unshift to reverse
this.newMessages.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,
})
}
})
this.pendingMessages.forEach(m => {
items.unshift({
items.push({
type: 'pending-message',
key: m.id,
message: {
@@ -453,109 +856,45 @@ export class Convo {
})
this.footerItems.forEach(item => {
items.unshift(item)
items.push(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]
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)
if (isMessage) {
if (
isConvoItemMessage(next) &&
next &&
(ChatBskyConvoDefs.isMessageView(next.message) ||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
isMessage &&
(ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.message))
) {
nextMessage = 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,
}
}
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)
return item
})
}
}
+15 -21
View File
@@ -1,14 +1,12 @@
import React, {useContext, useEffect, useMemo, useState} from 'react'
import React, {useContext, useState, useSyncExternalStore} from 'react'
import {BskyAgent} from '@atproto-labs/api'
import {useFocusEffect} from '@react-navigation/native'
import {Convo, ConvoParams} from '#/state/messages/convo'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<{
service: Convo
state: Convo['state']
} | null>(null)
const ChatContext = React.createContext<ConvoState | null>(null)
export function useChat() {
const ctx = useContext(ChatContext)
@@ -24,7 +22,7 @@ export function ChatProvider({
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [service] = useState(
const [convo] = useState(
() =>
new Convo({
convoId,
@@ -34,21 +32,17 @@ export function ChatProvider({
__tempFromUserDid: getAgent().session?.did!,
}),
)
const [state, setState] = useState(service.state)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
useEffect(() => {
service.initialize()
}, [service])
useFocusEffect(
React.useCallback(() => {
convo.resume()
useEffect(() => {
const update = () => setState(service.state)
service.on('update', update)
return () => {
service.destroy()
}
}, [service])
return () => {
convo.background()
}
}, [convo]),
)
const value = useMemo(() => ({service, state}), [service, state])
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
}
@@ -7,7 +7,7 @@ import {useHeaders} from './temp-headers'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvos() {
export function useListConvos({refetchInterval}: {refetchInterval: number}) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
@@ -24,5 +24,6 @@ export function useListConvos() {
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
refetchInterval,
})
}
+209 -227
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {networkRetry} from '#/lib/async/retry'
@@ -35,8 +35,6 @@ 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,
@@ -47,9 +45,7 @@ const ApiContext = React.createContext<SessionApiContext>({
login: async () => {},
logout: async () => {},
initSession: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
selectAccount: async () => {},
updateCurrentAccount: () => {},
clearCurrentAccount: () => {},
})
@@ -60,33 +56,26 @@ function __getAgent() {
return __globalAgent
}
type AgentState = {
readonly agent: BskyAgent
readonly did: string | undefined
}
type State = {
accounts: SessionStateContext['accounts']
currentAccountDid: string | undefined
currentAgentState: AgentState
needsPersist: boolean
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
const [state, setState] = React.useState<State>({
const [state, setState] = React.useState<State>(() => ({
accounts: persisted.get('session').accounts,
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,
}
})
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined, // assume logged out to start
},
[setState],
)
needsPersist: false,
}))
const clearCurrentAccount = React.useCallback(() => {
logger.warn(`session: clear current account`)
@@ -94,80 +83,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAccountDid: undefined,
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined,
},
needsPersist: true,
}))
}, [setState])
const createPersistSessionHandler = React.useCallback(
const onAgentSessionChange = React.useCallback(
(
agent: BskyAgent,
account: SessionAccount,
persistSessionCallback: (props: {
expired: boolean
refreshedAccount: SessionAccount
}) => void,
{
networkErrorCallback,
}: {
networkErrorCallback?: () => void
} = {},
): AtpPersistSessionHandler => {
return function persistSession(event, session) {
const expired = event === 'expired' || event === 'create-failed'
event: AtpSessionEvent,
session: AtpSessionData | undefined,
) => {
const expired = event === 'expired' || event === 'create-failed'
if (event === 'network-error') {
logger.warn(
`session: persistSessionHandler received network-error event`,
)
networkErrorCallback?.()
return
}
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
}
// 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()
}
// 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(),
/*
* 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.
* Tokens are undefined if the session expires, or if creation fails for
* any reason e.g. tokens are invalid, network error, etc.
*/
persistSessionCallback({
expired,
refreshedAccount,
})
refreshJwt: session?.refreshJwt,
accessJwt: session?.accessJwt,
}
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,
}
})
},
[],
)
@@ -197,26 +218,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
)
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
__globalAgent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
logger.debug(`session: created account`, {}, logger.DebugContext.session)
track('Create Account')
logEvent('account:create:success', {})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[onAgentSessionChange],
)
const login = React.useCallback<SessionApiContext['login']>(
@@ -229,35 +252,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
authFactorToken,
})
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
__globalAgent = agent
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
track('Sign In', {resumedSession: false})
logEvent('account:loggedIn', {logContext, withPassword: true})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[onAgentSessionChange],
)
const logout = React.useCallback<SessionApiContext['logout']>(
async logContext => {
logger.debug(`session: logout`)
clearCurrentAccount()
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => {
return {
accounts: s.accounts.map(a => ({
@@ -265,13 +292,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: undefined,
accessJwt: undefined,
})),
currentAccountDid: s.currentAccountDid,
currentAgentState: {
did: undefined,
agent: PUBLIC_BSKY_AGENT,
},
needsPersist: true,
}
})
logEvent('account:loggedOut', {logContext})
},
[clearCurrentAccount, setState],
[setState],
)
const initSession = React.useCallback<SessionApiContext['initSession']>(
@@ -286,16 +316,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
}
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
@@ -305,8 +328,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,
}
@@ -314,23 +337,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (isSessionExpired(account)) {
logger.debug(`session: attempting to resume using previous session`)
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?
}
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,
}
})
} else {
logger.debug(`session: attempting to reuse previous session`)
@@ -338,7 +360,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
__globalAgent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [
account,
...s.accounts.filter(a => a.did !== account.did),
],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
if (accountOrSessionDeactivated) {
// don't attempt to resume
@@ -349,26 +383,6 @@ 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> {
@@ -386,22 +400,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return sessionAccount
}
},
[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],
[onAgentSessionChange],
)
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
@@ -409,7 +408,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setState(s => {
return {
accounts: s.accounts.filter(a => a.did !== account.did),
currentAccountDid: s.currentAccountDid,
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
@@ -423,23 +422,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account => {
setState(s => {
const currentAccount = s.accounts.find(
a => a.did === s.currentAccountDid,
a => a.did === s.currentAgentState.did,
)
// 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 !== undefined
? account.emailConfirmed
: currentAccount.emailConfirmed,
account.emailConfirmed ?? currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor !== undefined
? account.emailAuthFactor
: currentAccount.emailAuthFactor,
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
}
return {
@@ -447,7 +442,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
updatedAccount,
...s.accounts.filter(a => a.did !== currentAccount.did),
],
currentAccountDid: s.currentAccountDid,
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
@@ -455,30 +450,13 @@ 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.currentAccountDid,
a => a.did === state.currentAgentState.did,
),
})
}
@@ -489,16 +467,21 @@ 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.currentAccountDid) {
if (selectedAccount.did !== state.currentAgentState.did) {
logger.debug(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAccountDid,
did: state.currentAgentState.did,
},
to: {
did: selectedAccount.did,
@@ -516,8 +499,9 @@ 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.currentAccountDid) {
} else if (!selectedAccount && state.currentAgentState.did) {
logger.debug(
`session: persisted onUpdate, logging out`,
{},
@@ -530,28 +514,30 @@ 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)
*/
clearCurrentAccount()
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.
}))
}
setState(() => ({
accounts: persistedSession.accounts,
currentAccountDid: selectedAccount?.did,
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
})
}, [state, setState, clearCurrentAccount, initSession])
}, [state, setState, initSession])
const stateContext = React.useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAccountDid,
a => a.did === state.currentAgentState.did,
),
isInitialLoad,
isSwitchingAccounts,
hasSession: !!state.currentAccountDid,
hasSession: !!state.currentAgentState.did,
}),
[state, isInitialLoad, isSwitchingAccounts],
[state],
)
const api = React.useMemo(
@@ -560,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
login,
logout,
initSession,
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
clearCurrentAccount,
}),
@@ -571,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
login,
logout,
initSession,
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
clearCurrentAccount,
],
-7
View File
@@ -6,8 +6,6 @@ export type SessionAccount = PersistedAccount
export type SessionStateContext = {
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
isInitialLoad: boolean
isSwitchingAccounts: boolean
hasSession: boolean
}
export type SessionApiContext = {
@@ -46,12 +44,7 @@ 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<
+37 -9
View File
@@ -59,6 +59,7 @@ 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'
@@ -327,7 +328,7 @@ export const ComposePost = observer(function ComposePost({
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: `ALT: ${gif.content_description}`,
description: '',
},
})
setExtGif(gif)
@@ -335,6 +336,26 @@ 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"
@@ -474,14 +495,21 @@ export const ComposePost = observer(function ComposePost({
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10]}>
+7 -2
View File
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
: undefined
return (
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
<View
style={[
!gif && a.mb_xl,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
{link.isLoading ? (
<Container style={loadingStyle}>
<Loader size="xl" />
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
</Container>
) : linkInfo ? (
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
<ExternalLinkEmbed link={linkInfo} />
<ExternalLinkEmbed link={linkInfo} hideAlt />
</View>
) : null}
<TouchableOpacity
+177
View File
@@ -0,0 +1,177 @@
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>
)
}
+41 -26
View File
@@ -1,19 +1,20 @@
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 {Text} from 'view/com/util/text/Text'
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 {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'
const IMAGE_GAP = 8
@@ -49,10 +50,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
@@ -126,16 +127,22 @@ 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: colors.green3}}
style={{color: t.palette.white}}
/>
) : undefined}
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
<TouchableOpacity
@@ -201,21 +208,28 @@ const GalleryInner = observer(function GalleryImpl({
</View>
))}
</View>
<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>
<AltTextReminder />
</>
) : 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,
@@ -244,6 +258,7 @@ const styles = StyleSheet.create({
paddingVertical: 3,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
altTextControlLabel: {
color: 'white',
@@ -1,5 +1,4 @@
import {useEffect, useState} from 'react'
import {useLingui} from '@lingui/react'
import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index'
@@ -9,7 +8,6 @@ import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({}: {
setQuote: (opts: ComposerOpts['quote']) => void
}) {
const {i18n} = useLingui()
const {getAgent} = useAgent()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
@@ -24,7 +22,7 @@ export function useExternalLinkFetch({}: {
return cleanup
}
if (!extLink.meta) {
getLinkMeta(getAgent(), extLink.uri, 5e3, i18n).then(meta => {
getLinkMeta(getAgent(), extLink.uri).then(meta => {
if (aborted) {
return
}
@@ -43,7 +41,7 @@ export function useExternalLinkFetch({}: {
})
}
return cleanup
}, [extLink, getAgent, i18n])
}, [extLink, getAgent])
return {extLink, setExtLink}
}
@@ -1,5 +1,4 @@
import {useEffect, useState} from 'react'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useFetchDid} from '#/state/queries/handle'
@@ -27,7 +26,6 @@ export function useExternalLinkFetch({
}: {
setQuote: (opts: ComposerOpts['quote']) => void
}) {
const {i18n} = useLingui()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
@@ -97,7 +95,7 @@ export function useExternalLinkFetch({
},
)
} else {
getLinkMeta(getAgent(), extLink.uri, 15e3, i18n).then(meta => {
getLinkMeta(getAgent(), extLink.uri).then(meta => {
if (aborted) {
return
}
@@ -139,7 +137,7 @@ export function useExternalLinkFetch({
})
}
return cleanup
}, [extLink, setQuote, getPost, fetchDid, getAgent, i18n])
}, [extLink, setQuote, getPost, fetchDid, getAgent])
return {extLink, setExtLink}
}
+3 -3
View File
@@ -371,11 +371,11 @@ export function PostThread({
],
)
if (error || !thread) {
if (!thread || !preferences || error) {
return (
<ListMaybePlaceholder
isLoading={(!preferences || !thread) && !error}
isError={!!error}
isLoading={!error}
isError={Boolean(error)}
noEmpty
onRetry={refetch}
errorTitle={error?.title}
+1 -6
View File
@@ -5,9 +5,7 @@ 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
@@ -25,6 +23,7 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
containWeb?: boolean
}
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
@@ -44,7 +43,6 @@ function ListImpl<ItemT>(
const isScrolledDown = useSharedValue(false)
const contextScrollHandlers = useScrollHandlers()
const pal = usePalette('default')
const gate = useGate()
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -106,9 +104,6 @@ function ListImpl<ItemT>(
scrollEventThrottle={1}
style={style}
ref={ref}
showsVerticalScrollIndicator={
isWeb || !gate('hide_vertical_scroll_indicators')
}
/>
)
}
+141 -28
View File
@@ -1,11 +1,13 @@
import React, {isValidElement, memo, useRef, startTransition} from 'react'
import React, {isValidElement, memo, startTransition, useRef} from 'react'
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
import {addStyle} from 'lib/styles'
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 {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {addStyle} from 'lib/styles'
export type ListMethods = any // TODO: Better types.
export type ListProps<ItemT> = Omit<
@@ -19,6 +21,7 @@ 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.
@@ -26,12 +29,15 @@ function ListImpl<ItemT>(
{
ListHeaderComponent,
ListFooterComponent,
containWeb,
contentContainerStyle,
data,
desktopFixedHeight,
headerOffset,
keyExtractor,
refreshing: _unsupportedRefreshing,
onStartReached,
onStartReachedThreshold = 0,
onEndReached,
onEndReachedThreshold = 0,
onRefresh: _unsupportedOnRefresh,
@@ -80,14 +86,64 @@ 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() {
window.scrollTo({top: 0})
getScrollableNode()?.scrollTo({top: 0})
},
scrollToOffset({
animated,
offset,
@@ -95,46 +151,74 @@ function ListImpl<ItemT>(
animated: boolean
offset: number
}) {
window.scrollTo({
getScrollableNode()?.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 ---
// --- onContentSizeChange, maintainVisibleContentPosition ---
const containerRef = useRef(null)
useResizeObserver(containerRef, onContentSizeChange)
// --- onScroll ---
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
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,
)
}
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,
)
})
React.useEffect(() => {
if (!isInsideVisibleTree) {
// Prevents hidden tabs from firing scroll events.
// Only one list is expected to be firing these at a time.
return
}
window.addEventListener('scroll', handleWindowScroll)
const element = getScrollableNode()
element?.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleWindowScroll)
element?.removeEventListener('scroll', handleScroll)
}
}, [isInsideVisibleTree, handleWindowScroll])
}, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
// --- onScrolledDownChange ---
const isScrolledDown = useRef(false)
@@ -148,6 +232,17 @@ function ListImpl<ItemT>(
}
}
// --- onStartReached ---
const onHeadVisibilityChange = useNonReactiveCallback(
(isHeadVisible: boolean) => {
if (isHeadVisible) {
onStartReached?.({
distanceFromStart: onStartReachedThreshold || 0,
})
}
},
)
// --- onEndReached ---
const onTailVisibilityChange = useNonReactiveCallback(
(isTailVisible: boolean) => {
@@ -160,7 +255,11 @@ function ListImpl<ItemT>(
)
return (
<View {...props} style={style} ref={nativeRef}>
<View
{...props}
// @ts-ignore web only
style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]}
ref={nativeRef}>
<Visibility
onVisibleChange={setIsInsideVisibleTree}
style={
@@ -178,9 +277,17 @@ 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>
@@ -193,8 +300,9 @@ function ListImpl<ItemT>(
))}
{onEndReached && (
<Visibility
topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
root={containWeb ? nativeRef.current : null}
onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
/>
)}
{footer}
@@ -255,11 +363,15 @@ 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 => {
@@ -281,14 +393,15 @@ let Visibility = ({
React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
rootMargin: `${topMargin} 0px 0px 0px`,
root,
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
})
const tail: Element | null = tailRef.current!
observer.observe(tail)
return () => {
observer.unobserve(tail)
}
}, [handleIntersection, topMargin])
}, [bottomMargin, handleIntersection, topMargin, root])
return (
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
+1 -9
View File
@@ -2,19 +2,11 @@ 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) {
const gate = useGate()
return (
<Animated.ScrollView
{...props}
showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')}
/>
)
return <Animated.ScrollView {...props} />
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, {memo} from 'react'
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
import {setStringAsync} from 'expo-clipboard'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyActorDefs,
AppBskyFeedPost,
@@ -160,7 +160,7 @@ let PostDropdownBtn = ({
const onCopyPostText = React.useCallback(() => {
const str = richTextToString(richText, true)
setStringAsync(str)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`))
}, [_, richText])
+3 -2
View File
@@ -1,9 +1,10 @@
import {AppBskyEmbedImages} from '@atproto/api'
import React, {ComponentProps, FC} from 'react'
import {StyleSheet, Text, Pressable, View} from 'react-native'
import {Pressable, StyleSheet, Text, View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from 'platform/detection'
type EventFunction = (index: number) => void
@@ -20,9 +20,11 @@ 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()
@@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({
}, [link.uri, externalEmbedPrefs])
if (embedPlayerParams?.source === 'tenor') {
return <GifEmbed params={embedPlayerParams} link={link} />
return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
}
return (
+71 -4
View File
@@ -1,14 +1,18 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/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 {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'
@@ -82,9 +86,11 @@ function PlaybackControls({
export function GifEmbed({
params,
link,
hideAlt,
}: {
params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal
hideAlt?: boolean
}) {
const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled()
@@ -111,7 +117,8 @@ export function GifEmbed({
}, [])
return (
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<View
style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
<View
style={[
a.rounded_sm,
@@ -133,9 +140,69 @@ export function GifEmbed({
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={link.description.replace('ALT: ', '')}
accessibilityLabel={link.description.replace('Alt text: ', '')}
/>
{!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',
},
})
+16 -14
View File
@@ -119,22 +119,24 @@ function HomeScreenReady({
const gate = useGate()
const mode = useMinimalShellMode()
const {isMobile} = useWebMediaQueries()
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)
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)
}
}
})
return () => {
listener.remove()
}
})
return () => {
listener.remove()
}
}, [setMinimalShellMode, mode, isMobile, gate])
}, [setMinimalShellMode, mode, isMobile, gate]),
)
const onPageSelected = React.useCallback(
(index: number) => {
@@ -20,8 +20,6 @@ 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'
@@ -38,7 +36,6 @@ export function ModerationBlockedAccounts({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const gate = useGate()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
@@ -168,9 +165,6 @@ export function ModerationBlockedAccounts({}: Props) {
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
showsVerticalScrollIndicator={
isWeb || !gate('hide_vertical_scroll_indicators')
}
/>
)}
</CenteredView>
@@ -20,8 +20,6 @@ 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'
@@ -38,7 +36,6 @@ export function ModerationMutedAccounts({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const gate = useGate()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
@@ -167,9 +164,6 @@ export function ModerationMutedAccounts({}: Props) {
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
showsVerticalScrollIndicator={
isWeb || !gate('hide_vertical_scroll_indicators')
}
/>
)}
</CenteredView>
+84 -95
View File
@@ -1,7 +1,5 @@
import React from 'react'
import {
ActivityIndicator,
Linking,
Platform,
Pressable,
StyleSheet,
@@ -41,7 +39,7 @@ import {
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useAnalytics} from 'lib/analytics/analytics'
import * as AppInfo from 'lib/app-info'
import {appVersion, BUNDLE_DATE, bundleInfo} from 'lib/app-info'
import {STATUS_PAGE_URL} from 'lib/constants'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
@@ -51,7 +49,6 @@ 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'
@@ -62,25 +59,40 @@ 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 {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useTheme} from '#/alf'
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}: {account: SessionAccount}) {
function SettingsAccountCard({
account,
pendingDid,
onPressSwitchAccount,
}: {
account: SessionAccount
pendingDid: string | null
onPressSwitchAccount: (
account: SessionAccount,
logContext: 'Settings',
) => void
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {isSwitchingAccounts, currentAccount} = useSession()
const t = useTheme()
const {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]}>
<View
style={[
pal.view,
styles.linkCard,
account.did === pendingDid && t.atoms.bg_contrast_25,
]}>
<View style={styles.avi}>
<UserAvatar
size={40}
@@ -112,7 +124,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out`)}
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
activeOpacity={0.8}>
<Text type="lg" style={pal.link}>
<Trans>Sign out</Trans>
</Text>
@@ -138,13 +151,12 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
testID={`switchToAccountBtn-${account.handle}`}
key={account.did}
onPress={
isSwitchingAccounts
? undefined
: () => onPressSwitchAccount(account, 'Settings')
pendingDid ? undefined : () => onPressSwitchAccount(account, 'Settings')
}
accessibilityRole="button"
accessibilityLabel={_(msg`Switch to ${account.handle}`)}
accessibilityHint={_(msg`Switches the account you are logged in to`)}>
accessibilityHint={_(msg`Switches the account you are logged in to`)}
activeOpacity={0.8}>
{contents}
</TouchableOpacity>
)
@@ -165,17 +177,14 @@ export function SettingsScreen({}: Props) {
const {isMobile} = useWebMediaQueries()
const {screen, track} = useAnalytics()
const {openModal} = useModalControls()
const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const {accounts, currentAccount} = useSession()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const birthdayControl = useDialogControl()
// TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED
const gate = useGate()
const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} =
useDmServiceUrlStorage()
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const isSwitchingAccounts = !!pendingDid
// const primaryBg = useCustomPalette<ViewStyle>({
// light: {backgroundColor: colors.blue0},
@@ -246,7 +255,7 @@ export function SettingsScreen({}: Props) {
const onPressBuildInfo = React.useCallback(() => {
setStringAsync(
`Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
`Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`,
)
Toast.show(_(msg`Copied build version to clipboard`))
}, [_])
@@ -283,10 +292,6 @@ export function SettingsScreen({}: Props) {
navigation.navigate('AccessibilitySettings')
}, [navigation])
const onPressStatusPage = React.useCallback(() => {
Linking.openURL(STATUS_PAGE_URL)
}, [])
const onPressBirthday = React.useCallback(() => {
birthdayControl.open()
}, [birthdayControl])
@@ -371,51 +376,54 @@ 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 style={[s.flexRow, styles.heading]}>
<Text type="xl-bold" style={pal.text}>
<Trans>Signed in as</Trans>
</Text>
<View style={s.flex1} />
<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>
{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]}>
@@ -786,22 +794,6 @@ 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
@@ -873,17 +865,9 @@ export function SettingsScreen({}: Props) {
accessibilityRole="button"
onPress={onPressBuildInfo}>
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
<Trans>Version {AppInfo.appVersion}</Trans>
</Text>
</TouchableOpacity>
<Text type="sm" style={[pal.textLight]}>
&nbsp; &middot; &nbsp;
</Text>
<TouchableOpacity
accessibilityRole="button"
onPress={onPressStatusPage}>
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
<Trans>Status page</Trans>
<Trans>
Version {appVersion} {bundleInfo}
</Trans>
</Text>
</TouchableOpacity>
</View>
@@ -905,6 +889,12 @@ 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>
@@ -1050,7 +1040,6 @@ const styles = StyleSheet.create({
footer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 18,
},
})
@@ -0,0 +1,98 @@
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>
</>
)
}
+106 -70
View File
@@ -1,8 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView, View} from 'react-native'
import {useSetThemePrefs} from '#/state/shell'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {isWeb} from 'platform/detection'
import {CenteredView} from '#/view/com/util/Views'
import {ListContained} from 'view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Breakpoints} from './Breakpoints'
@@ -18,77 +20,111 @@ import {Theming} from './Theming'
import {Typography} from './Typography'
export function Storybook() {
const t = useTheme()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
if (isWeb) return <StorybookInner />
return (
<ScrollView>
<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>
<StorybookInner />
</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>
)
}