Compare commits

..

2 Commits

Author SHA1 Message Date
Dan Abramov 4904c5067a [Session V2] Persist agent changes automatically 2024-05-01 14:20:24 +01:00
Eric Bailey 7d2b711b88 Session V2 2024-05-01 04:10:59 +01:00
139 changed files with 2405 additions and 4582 deletions
-2
View File
@@ -4,5 +4,3 @@ SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
EXPO_PUBLIC_BUNDLE_DATE=0
@@ -1,9 +1,9 @@
name: build-and-push-bskyweb-aws
on:
workflow_dispatch:
push:
branches:
- main
- 3p-moderators
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -54,6 +54,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
@@ -57,8 +57,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
-2
View File
@@ -65,8 +65,6 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -144,8 +144,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
@@ -224,8 +222,6 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -287,8 +283,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
+4 -4
View File
@@ -20,8 +20,8 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Check
run: cd bskyweb/ && make check
- name: Build (binary)
@@ -37,7 +37,7 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Lint
run: cd bskyweb/ && make lint
-1
View File
@@ -40,7 +40,6 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
RUN touch bskyweb/static/media/empty.txt
#
# Generate the embedr Go binary.
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 402 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 342 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 237 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

-2
View File
@@ -10,8 +10,6 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
static/media/*.png
static/media/empty.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
+1 -1
View File
@@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error {
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
View File
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.81.0",
"version": "1.80.0",
"private": true,
"engines": {
"node": ">=18"
@@ -15,7 +15,7 @@
"web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
@@ -50,7 +50,6 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0",
"@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
@@ -185,7 +184,6 @@
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.6.4",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
+40 -56
View File
@@ -4,6 +4,7 @@ import 'view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {RootSiblingParent} from 'react-native-root-siblings'
import {
initialWindowMetrics,
@@ -15,10 +16,8 @@ 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'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
@@ -34,7 +33,6 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
@@ -54,9 +52,8 @@ import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
@@ -64,61 +61,46 @@ function InnerApp() {
// init
useEffect(() => {
async function resumeSession(account?: SessionAccount) {
try {
if (account) {
await initSession(account)
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
} finally {
setIsReady(true)
}
}
const account = readLastActiveAccount()
resumeSession(account)
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession, _])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</Splash>
</ThemeProvider>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener>
<StatsigProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</Splash>
</Alf>
</SafeAreaProvider>
)
@@ -156,7 +138,9 @@ function App() {
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
<KeyboardProvider>
<InnerApp />
</KeyboardProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
+29 -57
View File
@@ -4,14 +4,10 @@ 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'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
@@ -25,85 +21,61 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import * as Toast from 'view/com/util/Toast'
import {ToastContainer} from 'view/com/util/Toast.web'
import {Shell} from 'view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
// init
useEffect(() => {
async function resumeSession(account?: SessionAccount) {
try {
if (account) {
await initSession(account)
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
} finally {
setIsReady(true)
}
}
const account = readLastActiveAccount()
resumeSession(account)
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
}, [resumeSession])
// wait for session to resume
if (!isReady) return null
if (isInitialLoad) return null
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</RootSiblingParent>
</ThemeProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</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 {MessagesScreen} from './screens/Messages/List'
import {MessagesListScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
@@ -462,8 +462,8 @@ function MessagesTabNavigator() {
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="Messages"
getComponent={() => MessagesScreen}
name="MessagesList"
getComponent={() => MessagesListScreen}
options={{requireAuth: true}}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="Messages"
getComponent={() => MessagesScreen}
name="MessagesList"
getComponent={() => MessagesListScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
@@ -570,7 +570,7 @@ const LINKING = {
return buildStateObject('HomeTab', 'Home', params)
}
if (name === 'Messages') {
return buildStateObject('MessagesTab', 'Messages', params)
return buildStateObject('MessagesTab', 'MessagesList', params)
}
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
return buildStateObject('HomeTab', name, params, [
+5 -10
View File
@@ -16,14 +16,12 @@ export function AccountList({
onSelectAccount,
onSelectOther,
otherLabel,
pendingDid,
}: {
onSelectAccount: (account: SessionAccount) => void
onSelectOther: () => void
otherLabel?: string
pendingDid: string | null
}) {
const {currentAccount, accounts} = useSession()
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
const t = useTheme()
const {_} = useLingui()
@@ -33,7 +31,6 @@ export function AccountList({
return (
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_md,
a.overflow_hidden,
@@ -46,7 +43,6 @@ export function AccountList({
account={account}
onSelect={onSelectAccount}
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
</React.Fragment>
@@ -54,7 +50,7 @@ export function AccountList({
<Button
testID="chooseAddAccountBtn"
style={[a.flex_1]}
onPress={pendingDid ? undefined : onPressAddAccount}
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
label={_(msg`Login to account that is not listed`)}>
{({hovered, pressed}) => (
<View
@@ -63,7 +59,8 @@ export function AccountList({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed) && t.atoms.bg_contrast_25,
(hovered || pressed || isSwitchingAccounts) &&
t.atoms.bg_contrast_25,
]}>
<Text
style={[
@@ -87,12 +84,10 @@ function AccountItem({
account,
onSelect,
isCurrentAccount,
isPendingAccount,
}: {
account: SessionAccount
onSelect: (account: SessionAccount) => void
isCurrentAccount: boolean
isPendingAccount: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -120,7 +115,7 @@ function AccountItem({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
+1 -1
View File
@@ -64,7 +64,7 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress'
'disabled' | 'onPress' | 'testID'
> &
AccessibilityProps &
VariantProps & {
+1 -1
View File
@@ -72,7 +72,7 @@ export function Error({
a.text_center,
t.atoms.text_contrast_high,
{lineHeight: 1.4},
gtMobile ? {width: 450} : [a.w_full, a.px_lg],
gtMobile && {width: 450},
]}>
{message}
</Text>
+12 -14
View File
@@ -1,29 +1,27 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
ItemIconProps,
ItemProps,
ItemTextProps,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from 'platform/detection'
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
export function useMemoControlContext() {
return React.useContext(Context)
+3 -10
View File
@@ -34,17 +34,10 @@ export function createPortalGroup() {
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)
return (
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
@@ -9,7 +9,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProfileShadow} from 'state/cache/profile-shadow'
+1 -3
View File
@@ -43,9 +43,7 @@ export function Outer({
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
{children}
</Dialog.ScrollableInner>
</Context.Provider>
+2 -2
View File
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner />
<MutedWordsInner control={control} />
</Dialog.Outer>
)
}
function MutedWordsInner() {
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
+1 -2
View File
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelectAccount = useCallback(
@@ -54,7 +54,6 @@ export function SwitchAccountDialog({
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</View>
</Dialog.ScrollableInner>
-83
View File
@@ -1,83 +0,0 @@
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
@@ -1,86 +0,0 @@
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>
)
}
-184
View File
@@ -1,184 +0,0 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useMuteConvo,
useUnmuteConvo,
} from '#/state/queries/messages/mute-conversation'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
let ConvoMenu = ({
convo,
profile,
onUpdateConvo,
control,
hideTrigger,
currentScreen,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: AppBskyActorDefs.ProfileViewBasic
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
control?: Menu.MenuControlProps
hideTrigger?: boolean
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat muted`))
},
onError: () => {
Toast.show(_(msg`Could not mute chat`))
},
})
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat unmuted`))
},
onError: () => {
Toast.show(_(msg`Could not unmute chat`))
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace('Messages')
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<Menu.Trigger label={_(msg`Chat settings`)}>
{({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,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="lg" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute notifications`)}
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute notifications</Trans>
) : (
<Trans>Mute notifications</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
{/* TODO(samuel): implement these */}
<Menu.Group>
<Menu.Item
label={_(msg`Block account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Block account</Trans>
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
/>
</Menu.Item>
<Menu.Item
label={_(msg`Report account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Report account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={leaveConvoControl}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={() => leaveConvo()}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
-198
View File
@@ -1,198 +0,0 @@
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 {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography'
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()
const isFromSelf = item.sender?.did === currentAccount?.did
const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(next) &&
next.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => {
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
if (item.id.length > 13) {
return false
}
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 3 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 3 minutes
return diff > 3 * 60 * 1000
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
lastInGroupRef.current = isLastInGroup
}
const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
return (
<View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.py_sm,
a.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},
]}>
<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}
/>
</View>
)
}
MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message,
isLastInGroup,
style,
}: {
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// if under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// if in the last day
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_2xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
MessageItemMetadata = React.memo(MessageItemMetadata)
function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
-141
View File
@@ -1,141 +0,0 @@
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)
+20 -37
View File
@@ -8,8 +8,7 @@ import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useModerationOpts} from '#/state/queries/preferences'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
@@ -18,7 +17,7 @@ import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
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 {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query'
import {Button} from '../Button'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
import {ListMaybePlaceholder} from '../Lists'
@@ -34,9 +33,9 @@ export function NewChat({
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
const {mutate: createChat} = useGetChatFromMembers({
onSuccess: data => {
onNewChat(data.convo.id)
onNewChat(data.chat.id)
},
onError: error => {
Toast.show(error.message)
@@ -55,7 +54,7 @@ export function NewChat({
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
icon={<Envelope size="xl" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
@@ -179,7 +178,7 @@ function SearchablePeopleList({
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<Dialog.Input
<TextField.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
@@ -198,7 +197,6 @@ function SearchablePeopleList({
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextField.Root>
</View>
@@ -213,35 +211,20 @@ function SearchablePeopleList({
ListHeaderComponent={
<>
{listHeader}
{searchText.length === 0 ? (
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
<Envelope width={64} fill={t.palette.contrast_200} />
<Text
style={[
a.text_lg,
a.text_center,
a.mt_md,
t.atoms.text_contrast_low,
]}>
<Trans>Search for someone to start a conversation with.</Trans>
</Text>
</View>
) : (
!actorAutocompleteData?.length && (
<ListMaybePlaceholder
isLoading={isFetching}
isError={isError}
onRetry={refetch}
hideBackButton={true}
emptyType="results"
sideBorders={false}
emptyMessage={
isError
? _(msg`No search results found for "${searchText}".`)
: _(msg`Could not load profiles. Please try again later.`)
}
/>
)
{searchText.length > 0 && !actorAutocompleteData?.length && (
<ListMaybePlaceholder
isLoading={isFetching}
isError={isError}
onRetry={refetch}
hideBackButton={true}
emptyType="results"
sideBorders={false}
emptyMessage={
isError
? _(msg`No search results found for "${searchText}".`)
: _(msg`Could not load profiles. Please try again later.`)
}
/>
)}
</>
}
-17
View File
@@ -1,17 +0,0 @@
import {useCallback, useRef} from 'react'
import {useFocusEffect} from '@react-navigation/native'
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
const firstTimeRef = useRef(true)
useFocusEffect(
useCallback(() => {
if (firstTimeRef.current) {
firstTimeRef.current = false
return
}
refetch()
}, [refetch]),
)
}
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
})
-4
View File
@@ -3,7 +3,3 @@ import {createSinglePathSVG} from './TEMPLATE'
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
})
export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z',
})
+3 -13
View File
@@ -4,17 +4,7 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE =
IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
IS_DEV ? 'development' : UPDATES_CHANNEL
})`
-15
View File
@@ -1,17 +1,2 @@
import {version} from '../../package.json'
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE = IS_DEV
? 0
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = version
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
-87
View File
@@ -9,90 +9,3 @@ export function dedupArray<T>(arr: T[]): T[] {
const s = new Set(arr)
return [...s]
}
/**
* Taken from @tanstack/query-core utils.ts
* Modified to support Date object comparisons
*
* This function returns `a` if `b` is deeply equal.
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
export function replaceEqualDeep(a: any, b: any): any {
if (a === b) {
return a
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime() ? a : b
}
const array = isPlainArray(a) && isPlainArray(b)
if (array || (isPlainObject(a) && isPlainObject(b))) {
const aItems = array ? a : Object.keys(a)
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
if (
!array &&
a[key] === undefined &&
b[key] === undefined &&
aItems.includes(key)
) {
copy[key] = undefined
equalItems++
} else {
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key] && a[key] !== undefined) {
equalItems++
}
}
}
return aSize === bSize && equalItems === aSize ? a : copy
}
return b
}
export function isPlainArray(value: unknown) {
return Array.isArray(value) && value.length === Object.keys(value).length
}
// Copied from: https://github.com/jonschlinkert/is-plain-object
export function isPlainObject(o: any): o is Object {
if (!hasObjectPrototype(o)) {
return false
}
// If has no constructor
const ctor = o.constructor
if (ctor === undefined) {
return true
}
// If has modified prototype
const prot = ctor.prototype
if (!hasObjectPrototype(prot)) {
return false
}
// If constructor does not have an Object-specific method
if (!prot.hasOwnProperty('isPrototypeOf')) {
return false
}
// Most likely a plain Object
return true
}
function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}
+13 -16
View File
@@ -1,4 +1,4 @@
import {useCallback, useState} from 'react'
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,14 +8,12 @@ import {isWeb} from '#/platform/detection'
import {SessionAccount, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {logEvent} from '../statsig/statsig'
import {LogEvents} from '../statsig/statsig'
export function useAccountSwitcher() {
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const {track} = useAnalytics()
const {initSession} = useSessionApi()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
@@ -24,12 +22,8 @@ export function useAccountSwitcher() {
logContext: LogEvents['account:loggedIn']['logContext'],
) => {
track('Settings:SwitchAccountButtonClicked')
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
return
}
try {
setPendingDid(account.did)
if (account.accessJwt) {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
@@ -39,9 +33,10 @@ export function useAccountSwitcher() {
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
await initSession(account)
logEvent('account:loggedIn', {logContext, withPassword: false})
Toast.show(_(msg`Signed in as @${account.handle}`))
await selectAccount(account, logContext)
setTimeout(() => {
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
} else {
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
@@ -53,12 +48,14 @@ export function useAccountSwitcher() {
logger.error(`switch account: selectAccount failed`, {
message: e.message,
})
} finally {
setPendingDid(null)
clearCurrentAccount() // back user out to login
setTimeout(() => {
Toast.show(_(msg`Sorry! We need you to enter your password.`))
}, 100)
}
},
[_, track, initSession, requestSwitchToAccount, pendingDid],
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
)
return {onPressSwitchAccount, pendingDid}
return {onPressSwitchAccount}
}
+1 -2
View File
@@ -11,9 +11,8 @@ export function useNavigationTabState() {
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
}
if (
!res.isAtHome &&
!res.isAtSearch &&
@@ -1,5 +1,4 @@
import {useNavigationState} from '@react-navigation/native'
import {getCurrentRoute} from 'lib/routes/helpers'
export function useNavigationTabState() {
@@ -10,7 +9,6 @@ export function useNavigationTabState() {
isAtSearch: currentRoute === 'Search',
isAtNotifications: currentRoute === 'Notifications',
isAtMyProfile: currentRoute === 'MyProfile',
isAtMessages: currentRoute === 'Messages',
}
})
}
+3 -3
View File
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: undefined
MessagesList: undefined
}
export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
Messages: undefined
MessagesList: undefined
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Messages: undefined
MessagesList: undefined
}
// NOTE
+2 -1
View File
@@ -1,9 +1,10 @@
export type Gate =
// Keep this alphabetic please.
| 'autoexpand_suggestions_on_profile_follow_v2'
| 'disable_min_shell_on_foregrounding_v3'
| 'disable_min_shell_on_foregrounding_v2'
| 'disable_poll_on_discover_v2'
| 'dms'
| 'hide_vertical_scroll_indicators'
| 'show_follow_back_label_v2'
| 'start_session_with_following_v2'
| 'test_gate_1'
-1
View File
@@ -9,5 +9,4 @@ 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',
Messages: '/messages',
MessagesList: '/messages',
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
})
+24 -41
View File
@@ -22,7 +22,6 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void
}) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
const {track, screen} = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -35,48 +34,33 @@ export const ChooseAccountForm = ({
const onSelect = React.useCallback(
async (account: SessionAccount) => {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
return
}
if (!account.accessJwt) {
// Move to login form.
if (account.accessJwt) {
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`))
} else {
try {
await initSession(account)
logEvent('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
track('Sign In', {resumedSession: true})
setTimeout(() => {
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
})
onSelectAccount(account)
}
}
} else {
onSelectAccount(account)
return
}
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`))
return
}
try {
setPendingDid(account.did)
await initSession(account)
logEvent('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
track('Sign In', {resumedSession: true})
Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
})
// Move to login form.
onSelectAccount(account)
} finally {
setPendingDid(null)
}
},
[
currentAccount,
track,
initSession,
pendingDid,
onSelectAccount,
setShowLoggedOut,
_,
],
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
)
return (
@@ -90,7 +74,6 @@ export const ChooseAccountForm = ({
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
@@ -1,111 +1,66 @@
import React from 'react'
import {
Dimensions,
Keyboard,
NativeSyntheticEvent,
Pressable,
TextInput,
TextInputContentSizeChangeEventData,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Pressable, TextInput, View} from 'react-native'
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'
import {Text} from '#/components/Typography'
export function MessageInput({
onSendMessage,
scrollToEnd,
onFocus,
onBlur,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
onFocus: () => void
onBlur: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const [message, setMessage] = React.useState('')
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
const {top: topInset} = useSafeAreaInsets()
const inputRef = React.useRef<TextInput>(null)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
playHaptic()
onSendMessage(message)
setMessage('')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, onSendMessage, playHaptic])
const onInputLayout = React.useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const keyboardHeight = Keyboard.metrics()?.height ?? 0
const windowHeight = Dimensions.get('window').height
const max = windowHeight - keyboardHeight - topInset - 100
const availableSpace = max - e.nativeEvent.contentSize.height
setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)
scrollToEnd()
},
[scrollToEnd, topInset],
)
}, [message, onSendMessage])
return (
<View style={a.p_sm}>
<View
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.rounded_full,
a.mt_sm,
t.atoms.bg_contrast_25,
]}>
<TextInput
accessibilityLabel="Text input field"
accessibilityHint="Write a message"
value={message}
onChangeText={setMessage}
placeholder="Write a message"
style={[a.flex_1, a.text_sm, a.px_sm, t.atoms.text]}
onSubmitEditing={onSubmit}
onFocus={onFocus}
onBlur={onBlur}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
ref={inputRef}
/>
<Pressable
accessibilityRole="button"
style={[
a.w_full,
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<TextInput
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Write a message`)}
placeholderTextColor={t.palette.contrast_500}
value={message}
multiline={true}
onChangeText={setMessage}
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
scrollEnabled={isInputScrollable}
blurOnSubmit={false}
onFocus={scrollToEnd}
onContentSizeChange={onInputLayout}
ref={inputRef}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}>
<Text style={a.text_md}>🐴</Text>
</Pressable>
</View>
)
}
@@ -1,94 +0,0 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import TextareaAutosize from 'react-textarea-autosize'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const [message, setMessage] = React.useState('')
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
setMessage('')
}, [message, onSendMessage])
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) return
e.preventDefault()
onSubmit()
}
},
[onSubmit],
)
const onChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
},
[],
)
return (
<View style={a.p_sm}>
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
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: 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,158 @@
import React, {useCallback, useMemo} from 'react'
import {StyleProp, TextStyle, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
export function MessageItem({
item,
next,
}: {
item: TempDmChatDefs.MessageView
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
}) {
const t = useTheme()
const {getAgent} = useAgent()
const isFromSelf = item.sender?.did === getAgent().session?.did
const isNextFromSelf =
TempDmChatDefs.isMessageView(next) &&
next.sender?.did === getAgent().session?.did
const isLastInGroup = useMemo(() => {
// 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
if (TempDmChatDefs.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
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
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
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
]}>
{item.text}
</Text>
</View>
<Metadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
/>
</View>
)
}
function Metadata({
message,
isLastInGroup,
style,
}: {
message: TempDmChatDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}) {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// in the last day
if (now.getDate() === date.getDate()) {
return time
}
// if yesterday
if (diff < 24 * 60 * 60 * 1000 && now.getDate() - date.getDate() === 1) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
@@ -1,60 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MessageListError({
item,
}: {
item: ConvoItem & {type: 'error-recoverable'}
}) {
const t = useTheme()
const {_} = useLingui()
const message = React.useMemo(() => {
return {
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.ResumeFailed]: _(
msg`There was an issue connecting to the chat.`,
),
[ConvoItemError.PollFailed]: _(
msg`This chat was disconnected due to a network error.`,
),
}[item.code]
}, [_, item.code])
return (
<View style={[a.py_md, a.align_center]}>
<View
style={[
a.align_center,
a.pt_md,
a.pb_lg,
a.px_3xl,
a.rounded_md,
t.atoms.bg_contrast_25,
{maxWidth: 300},
]}>
<CircleInfo size="lg" fill={t.palette.negative_400} />
<Text style={[a.pt_sm, a.leading_snug]}>
{message}{' '}
<InlineLinkText
to="#"
label={_(msg`Press to retry`)}
onPress={e => {
e.preventDefault()
item.retry()
return false
}}>
{_(msg`Retry.`)}
</InlineLinkText>
</Text>
</View>
</View>
)
}
+156 -210
View File
@@ -1,28 +1,24 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import {
KeyboardAvoidingView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {FlatList, View, ViewToken} from 'react-native'
import {Alert} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
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 {isWeb} from '#/platform/detection'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem'
import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
import {
useChat,
useChatLogQuery,
useSendMessageMutation,
} from '#/screens/Messages/Temp/query/query'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
type MessageWithNext = {
message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
}
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
@@ -38,221 +34,171 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
function RetryButton({onPress}: {onPress: () => unknown}) {
const {_} = useLingui()
function renderItem({item}: {item: MessageWithNext}) {
if (TempDmChatDefs.isMessageView(item.message))
return <MessageItem item={item.message} next={item.next} />
return (
<View style={{alignItems: 'center'}}>
<Button
label={_(msg`Press to Retry`)}
onPress={onPress}
variant="ghost"
color="negative"
size="small">
<ButtonText>
<Trans>Press to Retry</Trans>
</ButtonText>
</Button>
</View>
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return (
<MessageItem
item={item.message}
next={item.nextMessage}
pending={item.type === 'pending-message'}
/>
)
} 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} />
}
if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text>
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
// TODO rm
// TEMP: This is a temporary function to generate unique keys for mutation placeholders
const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}`
function onScrollToIndexFailed() {
function onScrollToEndFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList() {
const chat = useChat()
export function MessagesList({chatId}: {chatId: string}) {
const flatListRef = useRef<FlatList>(null)
// 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)
// Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true
// once the request for new posts starts. Then, we will change it back to false after the content size changes.
const isFetching = useRef(false)
// This will be used on web to assist in determing if we need to maintain the content offset
const isAtTop = useSharedValue(true)
// We use this to know if we should scroll after a new clop is added to the list
const isAtBottom = useRef(false)
// 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)
// Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
// total number of clops
// TODO this needs to be set to whatever the initial number of messages is
const totalMessages = useRef(10)
// 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)
// TODO later
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
const [_, setShowSpinner] = 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,
})
}
// Query Data
const {data: chat} = useChat(chatId)
const {mutate: sendMessage} = useSendMessageMutation(chatId)
useChatLogQuery()
contentHeight.value = height
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
return [
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
const firstVisibleIndex = info.viewableItems[0]?.index
// 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()
}
}, [chat, hasInitiallyScrolled])
const onSendMessage = useCallback(
(text: string) => {
if (chat.status === ConvoStatus.Ready) {
chat.sendMessage({
text,
})
}
},
[chat],
)
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
// when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1
// This number _must_ be the height of the MaybeLoader component.
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
// adds a 50 pixel offset.
if (contentHeight.value > 50 && !hasInitiallyScrolled) {
runOnJS(setHasInitiallyScrolled)(true)
}
},
[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})
isAtBottom.current = Number(firstVisibleIndex) < 2
},
{
itemVisiblePercentThreshold: 50,
minimumViewTime: 10,
},
]
}, [])
useKeyboardHandler({
onMove: () => {
'worklet'
runOnJS(scrollToEndNow)()
const onContentSizeChange = useCallback(() => {
if (isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
isFetching.current = false
setShowSpinner(false)
}, [])
const onEndReached = useCallback(() => {
if (isFetching.current) return
isFetching.current = true
setShowSpinner(true)
// Eventually we will add more here when we hit the top through RQuery
// We wouldn't actually use a timeout, but there would be a delay while loading
setTimeout(() => {
// Do something
setShowSpinner(false)
}, 1000)
}, [])
const onInputFocus = useCallback(() => {
if (!isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
}, [])
const onSendMessage = useCallback(
async (message: string) => {
if (!message) return
try {
sendMessage({
message,
tempId: generateUniqueKey(),
})
} catch (e: any) {
Alert.alert(e.toString())
}
},
})
[sendMessage],
)
const onInputBlur = useCallback(() => {}, [])
const messages = useMemo(() => {
if (!chat) return []
const filtered = chat.messages
.filter(
(
message,
): message is
| TempDmChatDefs.MessageView
| TempDmChatDefs.DeletedMessage => {
return (
TempDmChatDefs.isMessageView(message) ||
TempDmChatDefs.isDeletedMessage(message)
)
},
)
.reduce((acc, message) => {
// convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...]
return [...acc, {message, next: acc.at(-1)?.message ?? null}]
}, [] as MessageWithNext[])
totalMessages.current = filtered.length
return filtered
}, [chat])
return (
<KeyboardAvoidingView
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
behavior="padding"
contentContainerStyle={a.flex_1}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
<List
ref={flatListRef}
data={chat.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableVirtualization={true}
initialNumToRender={isWeb ? 50 : 25}
maxToRenderPerBatch={isWeb ? 50 : 25}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
containWeb={true}
contentContainerStyle={{paddingHorizontal: 10}}
removeClippedSubviews={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={chat.isFetchingHistory} />
}
keyboardVerticalOffset={70}
contentContainerStyle={{flex: 1}}>
<FlatList
data={messages}
keyExtractor={item => item.message.id}
renderItem={renderItem}
contentContainerStyle={{paddingHorizontal: 10}}
inverted={true}
// 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}
removeClippedSubviews={true}
onEndReached={onEndReached}
onScrollToIndexFailed={onScrollToEndFailed}
onContentSizeChange={onContentSizeChange}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
ListFooterComponent={<MaybeLoader isLoading={false} />}
ref={flatListRef}
keyboardDismissMode="none"
/>
<View style={{paddingHorizontal: 10}}>
<MessageInput
onSendMessage={onSendMessage}
onFocus={onInputFocus}
onBlur={onInputBlur}
/>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</View>
</KeyboardAvoidingView>
)
}
+50 -70
View File
@@ -1,9 +1,8 @@
import React, {useCallback} from 'react'
import React from 'react'
import {TouchableOpacity, View} from 'react-native'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {AppBskyActorDefs} 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 {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -12,13 +11,14 @@ import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {BACK_HITSLOP} from 'lib/constants'
import {isWeb} from 'platform/detection'
import {ChatProvider, useChat} from 'state/messages'
import {ConvoStatus} from 'state/messages/convo'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {useSession} from 'state/session'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {useChatQuery} from '#/screens/Messages/Temp/query/query'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Button, ButtonIcon} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
@@ -29,69 +29,47 @@ type Props = NativeStackScreenProps<
>
export function MessagesConversationScreen({route}: Props) {
const gate = useGate()
const convoId = route.params.conversation
const chatId = route.params.conversation
const {currentAccount} = useSession()
const myDid = currentAccount?.did
const {data: chat, isError: isError} = useChatQuery(chatId)
const otherProfile = React.useMemo(() => {
return chat?.members?.find(m => m.did !== myDid)
}, [chat?.members, myDid])
if (!gate('dms')) return <ClipClopGate />
return (
<ChatProvider convoId={convoId}>
<Inner />
</ChatProvider>
)
}
function Inner() {
const chat = useChat()
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 (
<KeyboardProvider>
if (!chat || !otherProfile) {
return (
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={chat.recipients[0]} />
<MessagesList />
<ListMaybePlaceholder isLoading={true} isError={isError} />
</CenteredView>
</KeyboardProvider>
)
}
return (
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={otherProfile} />
<MessagesList chatId={chatId} />
</CenteredView>
)
}
let Header = ({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
}): React.ReactNode => {
function Header({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const chat = useChat()
const onPressBack = useCallback(() => {
const onPressBack = React.useCallback(() => {
if (isWeb) {
navigation.replace('Messages')
navigation.replace('MessagesList')
} else {
navigation.pop()
}
}, [navigation])
const onUpdateConvo = useCallback(() => {
// TODO eric update muted state
}, [])
return (
<View
style={[
@@ -100,20 +78,22 @@ let Header = ({
a.border_b,
a.flex_row,
a.justify_between,
a.align_start,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
{!gtTablet ? (
<TouchableOpacity
testID="conversationHeaderBackBtn"
testID="viewHeaderDrawerBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
style={{
width: 30,
height: 30,
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
accessibilityHint={_(msg`Access navigation links and settings`)}>
<FontAwesomeIcon
size={18}
icon="angle-left"
@@ -126,23 +106,23 @@ let Header = ({
) : (
<View style={{width: 30}} />
)}
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
<PreviewableUserAvatar size={32} profile={profile} />
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
{profile.displayName}
<View style={[a.align_center, a.gap_sm]}>
<UserAvatar size={32} avatar={profile.avatar} />
<Text style={[a.text_lg, a.font_bold]}>
<Trans>{profile.displayName}</Trans>
</Text>
</View>
{chat.status === ConvoStatus.Ready ? (
<ConvoMenu
convo={chat.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
/>
) : (
<View style={{width: 30}} />
)}
<View>
<Button
label={_(msg`Chat settings`)}
color="secondary"
size="large"
variant="ghost"
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
onPress={() => {}}>
<ButtonIcon icon={DotGrid_Stroke2_Corner0_Rounded} />
</Button>
</View>
</View>
)
}
Header = React.memo(Header)
+36 -139
View File
@@ -2,59 +2,40 @@
import React, {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
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'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useListConvos} from '#/state/queries/messages/list-converations'
import {useSession} from '#/state/session'
import {useAgent} from '#/state/session'
import {List} from '#/view/com/util/List'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {useBreakpoints, useTheme} from '#/alf'
import {atoms as a} 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 {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useMenuControl} from '#/components/Menu'
import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
import {NewChat} from '../../../components/dms/NewChat'
import {ClipClopGate} from '../gate'
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
import {useListChats} from '../Temp/query/query'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen({navigation}: Props) {
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
export function MessagesListScreen({navigation}: Props) {
const {_} = useLingui()
const t = useTheme()
const newChatControl = useDialogControl()
const {gtMobile} = useBreakpoints()
// TEMP
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
const hasValidServiceUrl = useMemo(() => {
const hash = sha256(serviceUrl)
return (
hash ===
'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f'
)
}, [serviceUrl])
const renderButton = useCallback(() => {
return (
<Link
@@ -77,15 +58,13 @@ export function MessagesScreen({navigation}: Props) {
fetchNextPage,
error,
refetch,
} = useListConvos({refetchInterval: 15_000})
useRefreshOnFocus(refetch)
} = useListChats()
const isError = !!error
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
return data.pages.flatMap(page => page.chats)
}
return []
}, [data])
@@ -119,67 +98,28 @@ export function MessagesScreen({navigation}: Props) {
navigation.navigate('MessagesSettings')
}, [navigation])
const renderItem = useCallback(
({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
return <ChatListItem key={item.id} convo={item} />
},
[],
)
const renderItem = useCallback(({item}: {item: TempDmChatDefs.ChatView}) => {
return <ChatListItem key={item.id} chat={item} />
}, [])
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}>
{gtMobile ? (
<CenteredView sideBorders>
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
</CenteredView>
) : (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
{!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
<>
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
emptyType="results"
emptyTitle={_(msg`No messages yet`)}
emptyMessage={_(
msg`You have no messages yet. Start a conversation with someone!`,
)}
errorMessage={cleanError(error)}
onRetry={isError ? refetch : undefined}
hideBackButton
/>
</View>
<NewChat onNewChat={onNewChat} control={newChatControl} />
</>
)
}
@@ -225,28 +165,18 @@ export function MessagesScreen({navigation}: Props) {
)
}
function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const {getAgent} = useAgent()
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = _(msg`Message deleted`)
if (TempDmChatDefs.isMessageView(chat.lastMessage)) {
lastMessage = chat.lastMessage.text
}
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
const otherUser = chat.members.find(
member => member.did !== getAgent().session?.did,
)
if (!otherUser) {
@@ -254,10 +184,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
}
return (
<Link
to={`/messages/${convo.id}`}
style={a.flex_1}
onLongPress={isNative ? menuControl.open : undefined}>
<Link to={`/messages/${chat.id}`} style={a.flex_1}>
{({hovered, pressed}) => (
<View
style={[
@@ -273,65 +200,35 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
<PreviewableUserAvatar profile={otherUser} size={42} />
</View>
<View style={[a.flex_1]}>
<Text
numberOfLines={1}
style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
<Text
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
<Text numberOfLines={1} style={a.leading_snug}>
<Text style={[t.atoms.text, chat.unreadCount > 0 && a.font_bold]}>
{otherUser.displayName || otherUser.handle}
</Text>{' '}
{lastMessageSentAt ? (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle} &middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
) : (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
)}
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
</Text>
<Text
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_medium,
chat.unread ? a.font_bold : t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
</View>
{convo.unreadCount > 0 && (
{chat.unreadCount > 0 && (
<View
style={[
a.flex_0,
a.ml_md,
a.mt_sm,
a.ml_2xl,
a.mt_xs,
{backgroundColor: t.palette.primary_500},
a.rounded_full,
{
backgroundColor: convo.muted
? t.palette.contrast_200
: t.palette.primary_500,
height: 7,
width: 7,
},
{height: 7, width: 7},
]}
/>
)}
<ConvoMenu
convo={convo}
profile={otherUser}
control={menuControl}
// TODO(sam) show on hover on web
// tricky because it captures the mouse event
hideTrigger
currentScreen="list"
/>
</View>
)}
</Link>
@@ -387,7 +284,7 @@ function DesktopHeader({
variant="solid"
style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]}
onPress={newChatControl.open}>
<ButtonIcon icon={Plus} position="right" />
<ButtonIcon icon={Envelope} position="right" />
<ButtonText>
<Trans>New chat</Trans>
</ButtonText>
+301
View File
@@ -0,0 +1,301 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import * as TempDmChatDefs from '#/temp/dm/defs'
import * as TempDmChatGetChat from '#/temp/dm/getChat'
import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
import * as TempDmChatListChats from '#/temp/dm/listChats'
import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
/**
* TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
* (and do not try this at home)
*/
const useHeaders = () => {
const {getAgent} = useAgent()
return {
get Authorization() {
return getAgent().session!.did
},
}
}
type Chat = {
chatId: string
messages: TempDmChatGetChatMessages.OutputSchema['messages']
lastCursor?: string
lastRev?: string
}
export function useChat(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chat', chatId],
queryFn: async () => {
const currentChat = queryClient.getQueryData(['chat', chatId])
if (currentChat) {
return currentChat as Chat
}
const messagesResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
{
headers,
},
)
if (!messagesResponse.ok) throw new Error('Failed to fetch messages')
const messagesJson =
(await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const chatJson =
(await chatResponse.json()) as TempDmChatGetChat.OutputSchema
queryClient.setQueryData(['chatQuery', chatId], chatJson.chat)
const newChat = {
chatId,
messages: messagesJson.messages,
lastCursor: messagesJson.cursor,
lastRev: chatJson.chat.rev,
} satisfies Chat
queryClient.setQueryData(['chat', chatId], newChat)
return newChat
},
})
}
interface SendMessageMutationVariables {
message: string
tempId: string
}
export function createTempId() {
return Math.random().toString(36).substring(7).toString()
}
export function useSendMessageMutation(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation<
TempDmChatDefs.Message,
Error,
SendMessageMutationVariables,
unknown
>({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mutationFn: async ({message, tempId}) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
chatId,
message: {
text: message,
},
}),
},
)
if (!response.ok) throw new Error('Failed to send message')
return response.json()
},
onMutate: async variables => {
queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
return {
...prev,
messages: [
{
$type: 'temp.dm.defs#messageView',
id: variables.tempId,
text: variables.message,
sender: {did: headers.Authorization}, // TODO a real DID get
sentAt: new Date().toISOString(),
},
...prev.messages,
],
}
})
},
onSuccess: (result, variables) => {
queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
return {
...prev,
messages: prev.messages.map(m =>
m.id === variables.tempId ? {...m, id: result.id} : m,
),
}
})
},
onError: (_, variables) => {
console.log(_)
queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({
...prev,
messages: prev.messages.filter(m => m.id !== variables.tempId),
}))
},
})
}
export function useChatLogQuery() {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chatLog'],
queryFn: async () => {
const prevLog = queryClient.getQueryData([
'chatLog',
]) as TempDmChatGetChatLog.OutputSchema
try {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${
prevLog?.cursor ?? ''
}`,
{
headers,
},
)
if (!response.ok) throw new Error('Failed to fetch chat log')
const json =
(await response.json()) as TempDmChatGetChatLog.OutputSchema
for (const log of json.logs) {
if (TempDmChatDefs.isLogCreateMessage(log)) {
queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
// TODO hack filter out duplicates
if (prev?.messages.find(m => m.id === log.message.id)) return
return {
...prev,
messages: [log.message, ...prev.messages],
}
})
}
}
return json
} catch (e) {
console.log(e)
}
},
refetchInterval: 5000,
})
}
export function useGetChatFromMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
',',
)}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chat')
return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
},
onSuccess: data => {
queryClient.setQueryData(['chat', data.chat.id], {
chatId: data.chat.id,
messages: [],
lastRev: data.chat.rev,
})
onSuccess?.(data)
},
onError,
})
}
export function useListChats() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: ['chats'],
queryFn: async ({pageParam}) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.listChats${
pageParam ? `?cursor=${pageParam}` : ''
}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chats')
return (await response.json()) as TempDmChatListChats.OutputSchema
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function useChatQuery(chatId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chatQuery', chatId],
queryFn: async () => {
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const json = (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
return json.chat
},
})
}
@@ -2,13 +2,13 @@ import React from 'react'
import {View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, flatten, useTheme} from '#/alf'
import {useTheme, atoms as a, flatten} from '#/alf'
import {Text} from '#/components/Typography'
import {useItemContext} from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useModerationOpts} from '#/state/queries/preferences'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function SuggestedAccountCard({
profile,
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfilesQuery} from '#/state/queries/profile'
import {
DescriptionText,
@@ -26,7 +26,7 @@ export function CaptchaWebView({
stateParam: string
state?: SignupState
onSuccess: (code: string) => void
onError: (error: unknown) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!state?.serviceUrl) return 'bsky.app'
@@ -56,7 +56,7 @@ export function CaptchaWebView({
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError({error: 'Invalid state or code'})
onError()
return
}
@@ -74,12 +74,6 @@ export function CaptchaWebView({
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
onError={e => {
onError(e.nativeEvent)
}}
onHttpError={e => {
onError(e.nativeEvent)
}}
/>
)
}
@@ -13,20 +13,8 @@ export function CaptchaWebView({
url: string
stateParam: string
onSuccess: (code: string) => void
onError: (error: unknown) => void
onError: () => void
}) {
React.useEffect(() => {
const timeout = setTimeout(() => {
onError({
errorMessage: 'User did not complete the captcha within 30 seconds',
})
}, 30e3)
return () => {
clearTimeout(timeout)
}
}, [onError])
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
@@ -44,14 +32,12 @@ export function CaptchaWebView({
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError({error: 'Invalid state or code'})
onError()
return
}
onSuccess(code)
} catch (e: unknown) {
// We don't actually want to record an error here, because this will happen quite a bit. We will only be able to
// get hte href of the iframe if it's on our domain, so all the hcaptcha requests will throw here, although it's
// harmless. Our other indicators of time-to-complete and back press should be more reliable in catching issues.
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
+6 -14
View File
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
@@ -44,19 +43,12 @@ export function StepCaptcha() {
[submit],
)
const onError = React.useCallback(
(error?: unknown) => {
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
logger.error('Signup Flow Error', {
registrationHandle: state.handle,
error,
})
},
[_, dispatch, state.handle],
)
const onError = React.useCallback(() => {
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
}, [_, dispatch])
return (
<ScreenTransition>
+1 -10
View File
@@ -8,7 +8,6 @@ import {useAnalytics} from '#/lib/analytics/analytics'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {useAgent} from '#/state/session'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
@@ -120,19 +119,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
const onBackPress = React.useCallback(() => {
if (state.activeStep !== SignupStep.INFO) {
if (state.activeStep === SignupStep.CAPTCHA) {
logger.error('Signup Flow Error', {
errorMessage:
'User went back from captcha step. Possibly encountered an error.',
registrationHandle: state.handle,
})
}
dispatch({type: 'prev'})
} else {
onPressBack()
}
}, [onPressBack, state.activeStep, state.handle])
}, [onPressBack, state.activeStep])
return (
<SignupContext.Provider value={{state, dispatch}}>
+9 -10
View File
@@ -246,10 +246,6 @@ export function useSubmitSignup({
!verificationCode
) {
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
logger.error('Signup Flow Error', {
errorMessage: 'Verification captcha code was not set.',
registrationHandle: state.handle,
})
return dispatch({
type: 'setError',
value: _(msg`Please complete the verification captcha.`),
@@ -286,17 +282,20 @@ export function useSubmitSignup({
return
}
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {
message: e,
})
}
const error = cleanError(errMsg)
const isHandleError = error.toLowerCase().includes('handle')
dispatch({type: 'setIsLoading', value: false})
dispatch({type: 'setError', value: error})
dispatch({type: 'setError', value: cleanError(errMsg)})
dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
logger.error('Signup Flow Error', {
errorMessage: error,
registrationHandle: state.handle,
})
} finally {
dispatch({type: 'setIsLoading', value: false})
}
@@ -1,65 +0,0 @@
import {describe, it} from '@jest/globals'
describe(`#/state/messages/convo`, () => {
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(`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`, () => {
it.todo(`fetches initial chat history`)
it.todo(`fetches additional chat history`)
it.todo(`handles history fetch failure`)
it.todo(`does not insert deleted messages`)
})
describe(`sending messages`, () => {
it.todo(`optimistically adds sending messages`)
it.todo(`sends messages in order`)
it.todo(`failed message send fails all sending messages`)
it.todo(`can retry all failed messages via retry ConvoItem`)
it.todo(
`successfully sent messages are re-ordered, if needed, by events received from server`,
)
})
describe(`deleting messages`, () => {
it.todo(`messages are optimistically deleted from the chat`)
it.todo(`messages are confirmed deleted via events from the server`)
})
describe(`log handling`, () => {
it.todo(`updates rev to latest message received`)
it.todo(`only handles log events for this convoId`)
it.todo(`does not insert deleted messages`)
})
describe(`item ordering`, () => {
it.todo(`pending items are first, and in order`)
it.todo(`new message items are next, and in order`)
it.todo(`past message items are next, and in order`)
})
describe(`inactivity`, () => {
it.todo(
`below a certain threshold of inactivity, restore entirely from log`,
)
it.todo(
`above a certain threshold of inactivity, rehydrate entirely fresh state`,
)
})
})
-900
View File
@@ -1,900 +0,0 @@
import {AppBskyActorDefs} from '@atproto/api'
import {
BskyAgent,
ChatBskyConvoDefs,
ChatBskyConvoSendMessage,
} from '@atproto-labs/api'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
export type ConvoParams = {
convoId: string
agent: BskyAgent
__tempFromUserDid: string
}
export enum ConvoStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Resuming = 'resuming',
Ready = 'ready',
Error = 'error',
Backgrounded = 'backgrounded',
Suspended = 'suspended',
}
export enum ConvoItemError {
HistoryFailed = 'historyFailed',
ResumeFailed = 'resumeFailed',
PollFailed = 'pollFailed',
}
export enum ConvoError {
InitFailed = 'initFailed',
}
export type ConvoItem =
| {
type: 'message' | 'pending-message'
key: string
message: ChatBskyConvoDefs.MessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'deleted-message'
key: string
message: ChatBskyConvoDefs.DeletedMessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'pending-retry'
key: string
retry: () => void
}
| {
type: 'error-recoverable'
key: string
code: ConvoItemError
retry: () => void
}
export type ConvoState =
| {
status: ConvoStatus.Uninitialized
items: []
convo: undefined
error: undefined
sender: undefined
recipients: undefined
isFetchingHistory: false
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
}
| {
status: ConvoStatus.Initializing
items: []
convo: undefined
error: undefined
sender: undefined
recipients: undefined
isFetchingHistory: boolean
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
}
| {
status: ConvoStatus.Ready
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
error: undefined
sender: AppBskyActorDefs.ProfileViewBasic
recipients: AppBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
deleteMessage: (messageId: string) => Promise<void>
sendMessage: (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => void
fetchMessageHistory: () => void
}
| {
status: ConvoStatus.Suspended
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
error: undefined
sender: AppBskyActorDefs.ProfileViewBasic
recipients: AppBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
deleteMessage: (messageId: string) => Promise<void>
sendMessage: (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => Promise<void>
fetchMessageHistory: () => Promise<void>
}
| {
status: ConvoStatus.Backgrounded
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
error: undefined
sender: AppBskyActorDefs.ProfileViewBasic
recipients: AppBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
deleteMessage: (messageId: string) => Promise<void>
sendMessage: (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => Promise<void>
fetchMessageHistory: () => Promise<void>
}
| {
status: ConvoStatus.Resuming
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
error: undefined
sender: AppBskyActorDefs.ProfileViewBasic
recipients: AppBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
deleteMessage: (messageId: string) => Promise<void>
sendMessage: (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => Promise<void>
fetchMessageHistory: () => Promise<void>
}
| {
status: ConvoStatus.Error
items: []
convo: undefined
error: any
sender: undefined
recipients: undefined
isFetchingHistory: false
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
}
const ACTIVE_POLL_INTERVAL = 2e3
const BACKGROUND_POLL_INTERVAL = 10e3
export function isConvoItemMessage(
item: ConvoItem,
): item is ConvoItem & {type: 'message'} {
if (!item) return false
return (
item.type === 'message' ||
item.type === 'deleted-message' ||
item.type === 'pending-message'
)
}
export class Convo {
private agent: BskyAgent
private __tempFromUserDid: string
private pollInterval = ACTIVE_POLL_INTERVAL
private status: ConvoStatus = ConvoStatus.Uninitialized
private error:
| {
code: ConvoError
exception?: Error
retry: () => void
}
| undefined
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
private pollingFailure = false
private pastMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private newMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private pendingMessages: Map<
string,
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map()
private deletedMessages: Set<string> = new Set()
private footerItems: Map<string, ConvoItem> = new Map()
private headerItems: Map<string, ConvoItem> = new Map()
private pendingEventIngestion: Promise<void> | undefined
private isProcessingPendingMessages = false
convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined
sender: AppBskyActorDefs.ProfileViewBasic | undefined
recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined
snapshot: ConvoState | undefined
constructor(params: ConvoParams) {
this.convoId = params.convoId
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this)
this.deleteMessage = this.deleteMessage.bind(this)
this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
}
private commit() {
this.snapshot = undefined
this.subscribers.forEach(subscriber => subscriber())
}
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 {
this.isFetchingHistory = true
this.commit()
/*
* Delay if paginating while scrolled to prevent momentum scrolling from
* jerking the list around, plus makes it feel a little more human.
*/
if (this.pastMessages.size > 0) {
await new Promise(y => setTimeout(y, 500))
// throw new Error('UNCOMMENT TO TEST RETRY')
}
const response = await this.agent.api.chat.bsky.convo.getMessages(
{
cursor: this.historyCursor,
convoId: this.convoId,
limit: isNative ? 25 : 50,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {cursor, messages} = response.data
this.historyCursor = cursor ?? null
for (const message of messages) {
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()
}
}
private async pollEvents() {
if (
this.status === ConvoStatus.Ready ||
this.status === ConvoStatus.Backgrounded
) {
if (this.pendingEventIngestion) return
/*
* Represents a failed state, which is retryable.
*/
if (this.pollingFailure) return
setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion
this.pendingEventIngestion = undefined
this.pollEvents()
}, this.pollInterval)
}
}
async ingestLatestEvents() {
try {
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
let needsCommit = false
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') {
/*
* We only care about new events
*/
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
/*
* Update rev regardless of if it's a log type we care about or not
*/
this.eventsCursor = log.rev
/*
* This 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)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
*/
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()
/*
* If there are no pending messages, we're done.
*/
if (!pendingMessage) {
this.isProcessingPendingMessages = false
return
}
try {
this.isProcessingPendingMessages = true
// throw new Error('UNCOMMENT TO TEST RETRY')
const {id, message} = pendingMessage
const response = await this.agent.api.chat.bsky.convo.sendMessage(
{
convoId: this.convoId,
message,
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const res = response.data
/*
* Insert into `newMessages` as soon as we have a real ID. That way, when
* we get an event log back, we can replace in situ.
*/
this.newMessages.set(res.id, {
...res,
$type: 'chat.bsky.convo.defs#messageView',
sender: this.sender,
})
this.pendingMessages.delete(id)
await this.processPendingMessages()
this.commit()
} catch (e) {
this.footerItems.set('pending-retry', {
type: 'pending-retry',
key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this),
})
this.commit()
}
}
async batchRetryPendingMessages() {
logger.debug(
`Convo: retrying ${this.pendingMessages.size} pending messages`,
{},
logger.DebugContext.convo,
)
this.footerItems.delete('pending-retry')
this.commit()
try {
const messageArray = Array.from(this.pendingMessages.values())
const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
{
items: messageArray.map(({message}) => ({
convoId: this.convoId,
message,
})),
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {items} = data
/*
* Insert into `newMessages` as soon as we have a real ID. That way, when
* we get an event log back, we can replace in situ.
*/
for (const item of items) {
this.newMessages.set(item.id, {
...item,
$type: 'chat.bsky.convo.defs#messageView',
sender: this.convo?.members.find(
m => m.did === this.__tempFromUserDid,
),
})
}
for (const pendingMessage of messageArray) {
this.pendingMessages.delete(pendingMessage.id)
}
this.commit()
} catch (e) {
this.footerItems.set('pending-retry', {
type: 'pending-retry',
key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this),
})
this.commit()
}
}
async deleteMessage(messageId: string) {
logger.debug('Convo: delete message', {}, logger.DebugContext.convo)
this.deletedMessages.add(messageId)
this.commit()
try {
await this.agent.api.chat.bsky.convo.deleteMessageForSelf(
{
convoId: this.convoId,
messageId,
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
} catch (e) {
this.deletedMessages.delete(messageId)
this.commit()
throw e
}
}
/*
* Items in reverse order, since FlatList inverts
*/
getItems(): ConvoItem[] {
const items: ConvoItem[] = []
this.headerItems.forEach(item => {
items.push(item)
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.unshift({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.unshift({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
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.push({
type: 'pending-message',
key: m.id,
message: {
...m.message,
id: nanoid(),
rev: '__fake__',
sentAt: new Date().toISOString(),
sender: this.sender,
},
nextMessage: null,
})
})
this.footerItems.forEach(item => {
items.push(item)
})
return items
.filter(item => {
if (isConvoItemMessage(item)) {
return !this.deletedMessages.has(item.message.id)
}
return true
})
.map((item, i, arr) => {
let nextMessage = null
const isMessage = isConvoItemMessage(item)
if (isMessage) {
if (
isMessage &&
(ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.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
})
}
}
-48
View File
@@ -1,48 +0,0 @@
import React, {useContext, useState, useSyncExternalStore} from 'react'
import {BskyAgent} from '@atproto-labs/api'
import {useFocusEffect} from '@react-navigation/native'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<ConvoState | null>(null)
export function useChat() {
const ctx = useContext(ChatContext)
if (!ctx) {
throw new Error('useChat must be used within a ChatProvider')
}
return ctx
}
export function ChatProvider({
children,
convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [convo] = useState(
() =>
new Convo({
convoId,
agent: new BskyAgent({
service: serviceUrl,
}),
__tempFromUserDid: getAgent().session?.did!,
}),
)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
useFocusEffect(
React.useCallback(() => {
convo.resume()
return () => {
convo.background()
}
}, [convo]),
)
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
}
+4 -4
View File
@@ -1,11 +1,11 @@
import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {migrate} from '#/state/persisted/legacy'
import {defaults, Schema} from '#/state/persisted/schema'
import {migrate} from '#/state/persisted/legacy'
import * as store from '#/state/persisted/store'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
import BroadcastChannel from '#/lib/broadcast'
export type {Schema, PersistedAccount} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
-2
View File
@@ -80,7 +80,6 @@ export const schema = z.object({
pdsAddressHistory: z.array(z.string()).optional(),
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
@@ -118,5 +117,4 @@ export const defaults: Schema = {
pdsAddressHistory: [],
disableHaptics: false,
disableAutoplay: prefersReducedMotion,
kawaii: false,
}
+1 -4
View File
@@ -7,7 +7,6 @@ import {Provider as DisableHapticsProvider} from './disable-haptics'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as HiddenPostsProvider} from './hidden-posts'
import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages'
export {
@@ -33,9 +32,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<InAppBrowserProvider>
<DisableHapticsProvider>
<AutoplayProvider>
<DmServiceUrlProvider>
<KawaiiProvider>{children}</KawaiiProvider>
</DmServiceUrlProvider>
<DmServiceUrlProvider>{children}</DmServiceUrlProvider>
</AutoplayProvider>
</DisableHapticsProvider>
</InAppBrowserProvider>
-50
View File
@@ -1,50 +0,0 @@
import React from 'react'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['kawaii']
const stateContext = React.createContext<StateContext>(
persisted.defaults.kawaii,
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('kawaii'))
const setStateWrapped = React.useCallback(
(kawaii: persisted.Schema['kawaii']) => {
setState(kawaii)
persisted.write('kawaii', kawaii)
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('kawaii'))
})
}, [setStateWrapped])
React.useEffect(() => {
// dumb and stupid but it's web only so just refresh the page if you want to change it
if (isWeb) {
const kawaii = new URLSearchParams(window.location.search).get('kawaii')
switch (kawaii) {
case 'true':
setStateWrapped(true)
break
case 'false':
setStateWrapped(false)
break
}
}
}, [setStateWrapped])
return <stateContext.Provider value={state}>{children}</stateContext.Provider>
}
export function useKawaiiMode() {
return React.useContext(stateContext)
}
+5 -3
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api'
import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
import {useLabelDefinitionsQuery} from '../queries/preferences'
interface StateContext {
@@ -14,7 +13,10 @@ const stateContext = React.createContext<StateContext>({
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const state = useLabelDefinitionsQuery()
const {labelDefs, labelers} = useLabelDefinitionsQuery()
const state = {labelDefs, labelers}
return <stateContext.Provider value={state}>{children}</stateContext.Provider>
}
-61
View File
@@ -1,61 +0,0 @@
import React, {createContext, useContext, useMemo} from 'react'
import {BSKY_LABELER_DID, ModerationOpts} from '@atproto/api'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {useSession} from '#/state/session'
import {usePreferencesQuery} from '../queries/preferences'
export const moderationOptsContext = createContext<ModerationOpts | undefined>(
undefined,
)
// used in the moderation state devtool
export const moderationOptsOverrideContext = createContext<
ModerationOpts | undefined
>(undefined)
export function useModerationOpts() {
return useContext(moderationOptsContext)
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const {labelDefs} = useLabelDefinitions()
const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
const userDid = currentAccount?.did
const moderationPrefs = prefs.data?.moderationPrefs
const value = useMemo<ModerationOpts | undefined>(() => {
if (override) {
return override
}
if (!moderationPrefs) {
return undefined
}
return {
userDid,
prefs: {
...moderationPrefs,
labelers: moderationPrefs.labelers.length
? moderationPrefs.labelers
: [
{
did: BSKY_LABELER_DID,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
},
],
hiddenPosts: hiddenPosts || [],
},
labelDefs,
}
}, [override, userDid, labelDefs, moderationPrefs, hiddenPosts])
return (
<moderationOptsContext.Provider value={value}>
{children}
</moderationOptsContext.Provider>
)
}
+1 -2
View File
@@ -6,8 +6,7 @@ import {isJustAMute} from '#/lib/moderation'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences'
import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
@@ -1,25 +0,0 @@
import {BskyAgent} from '@atproto-labs/api'
import {useQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery(convoId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: RQKEY(convoId),
queryFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvo(
{convoId},
{headers},
)
return data.convo
},
})
}
@@ -1,39 +0,0 @@
import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {useHeaders} from './temp-headers'
export function useGetConvoForMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvoForMembers(
{members: members},
{headers},
)
return data
},
onSuccess: data => {
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -1,68 +0,0 @@
import {
BskyAgent,
ChatBskyConvoLeaveConvo,
ChatBskyConvoListConvos,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useLeaveConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.leaveConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onMutate: () => {
queryClient.setQueryData(
CONVO_LIST_KEY,
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
console.log('old', old)
if (!old) return old
return {
...old,
pages: old.pages.map(page => {
return {
...page,
convos: page.convos.filter(convo => convo.id !== convoId),
}
}),
}
},
)
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onError?.(error)
},
})
}
@@ -1,29 +0,0 @@
import {BskyAgent} from '@atproto-labs/api'
import {useInfiniteQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvos({refetchInterval}: {refetchInterval: number}) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.listConvos(
{cursor: pageParam},
{headers},
)
return data
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
refetchInterval,
})
}
@@ -1,84 +0,0 @@
import {
BskyAgent,
ChatBskyConvoMuteConvo,
ChatBskyConvoUnmuteConvo,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useMuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.muteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
export function useUnmuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -1,11 +0,0 @@
import {useSession} from '#/state/session'
// toy auth
export const useHeaders = () => {
const {currentAccount} = useSession()
return {
get Authorization() {
return currentAccount!.did
},
}
}
+1 -1
View File
@@ -28,8 +28,8 @@ import {
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {useModerationOpts} from '../preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
import {FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
+1 -1
View File
@@ -13,7 +13,7 @@ import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {useModerationOpts} from '../preferences'
import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {CachedFeedPage, FeedPage} from './types'
+1 -2
View File
@@ -32,8 +32,7 @@ import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
import {usePreferencesQuery} from './preferences'
import {useModerationOpts, usePreferencesQuery} from './preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
type ActorDid = string
+45 -3
View File
@@ -1,24 +1,28 @@
import {createContext, useContext, useMemo} from 'react'
import {
AppBskyActorDefs,
BSKY_LABELER_DID,
BskyFeedViewPreference,
LabelPreference,
ModerationOpts,
} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics'
import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {STALE} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
DEFAULT_THREAD_VIEW_PREFS,
} from '#/state/queries/preferences/const'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {
ThreadViewPreferences,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
import {useAgent} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config'
export * from '#/state/queries/preferences/const'
@@ -32,7 +36,7 @@ export function usePreferencesQuery() {
const {getAgent} = useAgent()
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: replaceEqualDeep,
structuralSharing: true,
refetchOnWindowFocus: true,
queryKey: preferencesQueryKey,
queryFn: async () => {
@@ -75,6 +79,44 @@ export function usePreferencesQuery() {
})
}
// used in the moderation state devtool
export const moderationOptsOverrideContext = createContext<
ModerationOpts | undefined
>(undefined)
export function useModerationOpts() {
const override = useContext(moderationOptsOverrideContext)
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const {labelDefs} = useLabelDefinitions()
const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
const opts = useMemo<ModerationOpts | undefined>(() => {
if (override) {
return override
}
if (!prefs.data) {
return
}
return {
userDid: currentAccount?.did,
prefs: {
...prefs.data.moderationPrefs,
labelers: prefs.data.moderationPrefs.labelers.length
? prefs.data.moderationPrefs.labelers
: [
{
did: BSKY_LABELER_DID,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
},
],
hiddenPosts: hiddenPosts || [],
},
labelDefs,
}
}, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
return opts
}
export function useClearPreferencesMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
+4 -2
View File
@@ -18,9 +18,11 @@ import {
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {
useModerationOpts,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '../preferences/moderation-opts'
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
+329 -365
View File
@@ -1,17 +1,14 @@
import React from 'react'
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
import {
agentToSessionAccount,
configureModerationForAccount,
@@ -20,21 +17,28 @@ import {
createAgentAndLogin,
isSessionDeactivated,
isSessionExpired,
} from './util'
export type {SessionAccount} from '#/state/session/types'
import {
SessionAccount,
SessionApiContext,
SessionStateContext,
} from '#/state/session/types'
sessionAccountToAgentSession,
} from '#/state/session/util'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import * as Toast from '#/view/com/util/Toast'
import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
export type {CurrentAccount, SessionAccount} from '#/state/session/types'
export {isSessionDeactivated}
const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
/**
* Only used for the initial agent values in state and context. Replaced
* immediately, and should not be reused.
*/
const INITIAL_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
configureModerationForGuest()
const StateContext = React.createContext<SessionStateContext>({
currentAgent: INITIAL_AGENT,
isInitialLoad: true,
isSwitchingAccounts: false,
accounts: [],
currentAccount: undefined,
hasSession: false,
@@ -45,153 +49,135 @@ const ApiContext = React.createContext<SessionApiContext>({
login: async () => {},
logout: async () => {},
initSession: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
updateCurrentAccount: () => {},
selectAccount: async () => {},
refreshSession: () => {},
clearCurrentAccount: () => {},
updateCurrentAccount: async () => {},
})
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
function __getAgent() {
return __globalAgent
}
type AgentState = {
readonly agent: BskyAgent
readonly did: string | undefined
}
type State = {
accounts: SessionStateContext['accounts']
currentAgentState: AgentState
needsPersist: boolean
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState<State>(() => ({
const [state, setState] = React.useState(() => ({
accounts: persisted.get('session').accounts,
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined, // assume logged out to start
},
currentAgent: INITIAL_AGENT,
needsPersist: false,
}))
const {accounts, currentAgent} = state
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
const currentAccountDid = React.useMemo(
() => currentAgent.session?.did,
[currentAgent],
)
const currentAccount = React.useMemo(
() => accounts.find(a => a.did === currentAccountDid),
[accounts, currentAccountDid],
)
const switchAccountAndPersist = React.useCallback((newAgent: BskyAgent) => {
const account = agentToSessionAccount(newAgent)
setState(prev => {
let nextAccounts = prev.accounts
if (account) {
nextAccounts = [
account,
...prev.accounts.filter(a => a.did !== account.did),
]
}
return {
accounts: nextAccounts,
currentAgent: newAgent,
needsPersist: true,
}
})
}, [])
const clearCurrentAccount = React.useCallback(() => {
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,
}))
}, [setState])
const onAgentSessionChange = React.useCallback(
(
agent: BskyAgent,
account: SessionAccount,
event: AtpSessionEvent,
session: AtpSessionData | undefined,
) => {
// immediate clear this so any pending requests don't use it
currentAgent.setPersistSessionHandler(() => {})
const newAgent = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
configureModerationForGuest()
switchAccountAndPersist(newAgent)
}, [currentAgent, switchAccountAndPersist])
React.useEffect(() => {
/*
* This method is continually overwritten when `currentAgent` and dependent
* methods local to this file change, so that the freshest agent and
* handlers are always used.
*/
currentAgent.setPersistSessionHandler(event => {
logger.debug(
`session: persistSession`,
{event},
logger.DebugContext.session,
)
const expired = event === 'expired' || event === 'create-failed'
/*
* Special case for a network error that occurs when calling
* `resumeSession`, which happens on page load, when switching
* accounts, or when refreshing user session data.
*
* When this occurs, we drop the user back out to the login screen, but
* we don't clear tokens, allowing them to quickly log back in when their
* connection improves.
*/
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,
}))
emitSessionDropped()
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Your internet connection is unstable. Please try again.`)
}, 100)
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 the session was expired naturally, we want to drop the user back
* out to log in.
*/
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,
}))
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Sorry! We need you to enter your password.`)
}, 100)
}
/*
* 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.
/**
* The updated account object, derived from the updated session we just
* received from this callback.
*/
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,
}
})
},
[],
)
if (currentAgent.session) {
/*
* If the session expired naturally, or it was otherwise successfully
* created/updated, we want to update/persist the data.
*/
switchAccountAndPersist(currentAgent)
} else {
/*
* This should never happen based on current `AtpAgent` handling, but
* it's here for TypeScript, and should result in the same handling as
* a session expiration.
*/
logger.error(`session: persistSession failed to get refreshed account`)
emitSessionDropped()
clearCurrentAccount()
setTimeout(() => {
Toast.show(`Sorry! We need you to enter your password.`)
}, 100)
}
})
}, [currentAgent, clearCurrentAccount, switchAccountAndPersist])
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
async ({
@@ -206,271 +192,221 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logger.info(`session: creating account`)
track('Try Create Account')
logEvent('account:create:begin', {})
const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
{
service,
email,
password,
handle,
inviteCode,
verificationPhone,
verificationCode,
},
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
const {agent, fetchingGates} = await createAgentAndCreateAccount({
service,
email,
password,
handle,
inviteCode,
verificationPhone,
verificationCode,
})
__globalAgent = agent
await fetchingGates
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
switchAccountAndPersist(agent)
logger.debug(`session: created account`, {}, logger.DebugContext.session)
track('Create Account')
logEvent('account:create:success', {})
},
[onAgentSessionChange],
[switchAccountAndPersist],
)
const login = React.useCallback<SessionApiContext['login']>(
async ({service, identifier, password, authFactorToken}, logContext) => {
logger.debug(`session: login`, {}, logger.DebugContext.session)
const {agent, account, fetchingGates} = await createAgentAndLogin({
const {agent, fetchingGates} = await createAgentAndLogin({
service,
identifier,
password,
authFactorToken,
})
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
__globalAgent = agent
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await fetchingGates
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
switchAccountAndPersist(agent)
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
track('Sign In', {resumedSession: false})
logEvent('account:loggedIn', {logContext, withPassword: true})
},
[onAgentSessionChange],
[switchAccountAndPersist],
)
const logout = React.useCallback<SessionApiContext['logout']>(
async logContext => {
logger.debug(`session: logout`)
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => {
return {
accounts: s.accounts.map(a => ({
...a,
refreshJwt: undefined,
accessJwt: undefined,
})),
currentAgentState: {
did: undefined,
agent: PUBLIC_BSKY_AGENT,
},
needsPersist: true,
}
})
clearCurrentAccount()
setState(prev => ({
accounts: prev.accounts.map(a => ({
...a,
accessJwt: undefined,
refreshJwt: undefined,
})),
currentAgent: prev.currentAgent,
needsPersist: true,
}))
logEvent('account:loggedOut', {logContext})
},
[setState],
[clearCurrentAccount],
)
const initSession = React.useCallback<SessionApiContext['initSession']>(
async account => {
logger.debug(`session: initSession`, {}, logger.DebugContext.session)
const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
const agent = new BskyAgent({service: account.service})
const newAgent = new BskyAgent({
service: account.service,
})
// restore the correct PDS URL if available
if (account.pdsUrl) {
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
newAgent.pdsUrl = newAgent.api.xrpc.uri = new URL(account.pdsUrl)
}
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await configureModerationForAccount(agent, account)
const accountOrSessionDeactivated =
isSessionDeactivated(account.accessJwt) || account.deactivated
const prevSession = {
accessJwt: account.accessJwt ?? '',
refreshJwt: account.refreshJwt ?? '',
did: account.did,
handle: account.handle,
...account,
accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
}
/**
* Optimistically update moderation services so that when the new agent
* is applied, they're ready.
*
* If session resumption fails, this will be reset by
* `clearCurrentAccount`.
*/
await configureModerationForAccount(newAgent, account)
if (isSessionExpired(account)) {
logger.debug(`session: attempting to resume using previous session`)
const freshAccount = await resumeSessionWithFreshAccount()
__globalAgent = agent
await fetchingGates
setState(s => {
return {
accounts: [
freshAccount,
...s.accounts.filter(a => a.did !== freshAccount.did),
],
currentAgentState: {
did: freshAccount.did,
agent: agent,
},
needsPersist: true,
}
})
} else {
logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession
__globalAgent = agent
await fetchingGates
setState(s => {
return {
accounts: [
account,
...s.accounts.filter(a => a.did !== account.did),
],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
if (accountOrSessionDeactivated) {
// don't attempt to resume
// use will be taken to the deactivated screen
logger.debug(`session: reusing session for deactivated account`)
return
}
// Intentionally not awaited to unblock the UI:
resumeSessionWithFreshAccount()
}
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
logger.debug(`session: resumeSessionWithFreshAccount`)
await networkRetry(1, () => agent.resumeSession(prevSession))
const sessionAccount = agentToSessionAccount(agent)
/*
* If `agent.resumeSession` fails above, it'll throw. This is just to
* make TypeScript happy.
* If session is expired, attempt to refresh the session using the
* refresh token via `resumeSession`
*/
if (!sessionAccount) {
throw new Error(`session: initSession failed to establish a session`)
}
return sessionAccount
logger.debug(
`session: attempting to resumeSession using previous session`,
{},
logger.DebugContext.session,
)
await networkRetry(1, () => newAgent.resumeSession(prevSession))
switchAccountAndPersist(newAgent)
} else {
/*
* If the session is not expired, assume we can reuse it.
*/
logger.debug(
`session: attempting to reuse previous session`,
{},
logger.DebugContext.session,
)
newAgent.session = prevSession
switchAccountAndPersist(newAgent)
}
},
[onAgentSessionChange],
[switchAccountAndPersist],
)
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, setIsInitialLoad],
)
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
account => {
setState(s => {
return {
accounts: s.accounts.filter(a => a.did !== account.did),
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
setState(prev => ({
accounts: prev.accounts.filter(a => a.did !== account.did),
currentAgent: prev.currentAgent,
needsPersist: true,
}))
},
[setState],
[],
)
const updateCurrentAccount = React.useCallback<
SessionApiContext['updateCurrentAccount']
>(
account => {
setState(s => {
const currentAccount = s.accounts.find(
a => a.did === s.currentAgentState.did,
)
// ignore, should never happen
if (!currentAccount) return s
const refreshSession = React.useCallback<
SessionApiContext['refreshSession']
>(async () => {
const {accounts: persistedAccounts} = persisted.get('session')
const selectedAccount = persistedAccounts.find(
a => a.did === currentAccountDid,
)
if (!selectedAccount) return
const updatedAccount = {
...currentAccount,
handle: account.handle ?? currentAccount.handle,
email: account.email ?? currentAccount.email,
emailConfirmed:
account.emailConfirmed ?? currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
}
// update and swap agent to trigger render refresh
const newAgent = currentAgent.clone()
await newAgent.resumeSession(sessionAccountToAgentSession(selectedAccount)!)
const refreshedAccount = agentToSessionAccount(newAgent)
await configureModerationForAccount(newAgent, refreshedAccount!)
switchAccountAndPersist(newAgent)
}, [currentAccountDid, currentAgent, switchAccountAndPersist])
return {
accounts: [
updatedAccount,
...s.accounts.filter(a => a.did !== currentAccount.did),
],
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
const updateCurrentAccount = React.useCallback(async () => {
await refreshSession()
}, [refreshSession])
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
}
},
[setState],
[setIsSwitchingAccounts, initSession],
)
React.useEffect(() => {
if (state.needsPersist) {
state.needsPersist = false
const currentAccountDid = state.currentAgent.session?.did
const currentAccount = state.accounts.find(
a => a.did === currentAccountDid,
)
persisted.write('session', {
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAgentState.did,
),
currentAccount: currentAccount,
})
}
}, [state])
React.useEffect(() => {
return persisted.onUpdate(() => {
return persisted.onUpdate(async () => {
const persistedSession = persisted.get('session')
logger.debug(`session: persisted onUpdate`, {})
setState(s => ({
logger.debug(
`session: persisted onUpdate`,
{},
logger.DebugContext.session,
)
/*
* Accounts are already persisted on other side of broadcast, but we need
* to update them in memory in this tab.
*/
setState(prev => ({
accounts: persistedSession.accounts,
currentAgentState: s.currentAgentState,
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
currentAgent: prev.currentAgent,
needsPersist: false, // We're syncing with another tab which already did that.
}))
const selectedAccount = persistedSession.accounts.find(
@@ -478,30 +414,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
if (selectedAccount && selectedAccount.refreshJwt) {
if (selectedAccount.did !== state.currentAgentState.did) {
logger.debug(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAgentState.did,
if (selectedAccount?.did !== currentAccountDid) {
logger.debug(
`session: persisted onUpdate, switching accounts`,
{
from: {
did: currentAccountDid,
},
to: {
did: selectedAccount.did,
},
},
to: {
did: selectedAccount.did,
},
})
logger.DebugContext.session,
)
initSession(selectedAccount)
await initSession(selectedAccount)
} else {
logger.debug(`session: persisted onUpdate, updating session`, {})
logger.debug(
`session: persisted onUpdate, updating session`,
{},
logger.DebugContext.session,
)
/*
* Use updated session in this tab's agent. Do not call
* upsertAccount, since that will only persist the session that's
* already persisted, and we'll get a loop between tabs.
* Create a new agent for the same account, with updated data from
* other side of broadcast. Update on state to re-derive
* `currentAccount` and re-render the app.
*/
// @ts-ignore we checked for `refreshJwt` above
__globalAgent.session = selectedAccount
// TODO: This needs a setState.
const newAgent = currentAgent.clone()
newAgent.session = sessionAccountToAgentSession(selectedAccount)
await configureModerationForAccount(newAgent, selectedAccount)
setState(prev => ({
accounts: prev.accounts,
currentAgent: newAgent,
needsPersist: false, // We're syncing with another tab which already did that.
}))
}
} else if (!selectedAccount && state.currentAgentState.did) {
} else if (!selectedAccount && currentAccountDid) {
logger.debug(
`session: persisted onUpdate, logging out`,
{},
@@ -514,30 +462,27 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* handled by `persistSession` (which nukes this accounts tokens only),
* or by a `logout` call which nukes all accounts tokens)
*/
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAgentState: {
did: undefined,
agent: PUBLIC_BSKY_AGENT,
},
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
clearCurrentAccount()
}
})
}, [state, setState, initSession])
}, [currentAccountDid, clearCurrentAccount, initSession, currentAgent])
const stateContext = React.useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAgentState.did,
),
hasSession: !!state.currentAgentState.did,
currentAgent,
isInitialLoad,
isSwitchingAccounts,
currentAccount,
accounts,
hasSession: Boolean(currentAccount),
}),
[state],
[
currentAgent,
isInitialLoad,
isSwitchingAccounts,
accounts,
currentAccount,
],
)
const api = React.useMemo(
@@ -546,21 +491,32 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
login,
logout,
initSession,
resumeSession,
removeAccount,
updateCurrentAccount,
selectAccount,
refreshSession,
clearCurrentAccount,
updateCurrentAccount,
}),
[
createAccount,
login,
logout,
initSession,
resumeSession,
removeAccount,
updateCurrentAccount,
selectAccount,
refreshSession,
clearCurrentAccount,
updateCurrentAccount,
],
)
if (IS_DEV && isWeb) {
// @ts-ignore
window.agent = currentAgent
}
return (
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
@@ -578,8 +534,8 @@ export function useSessionApi() {
export function useRequireAuth() {
const {hasSession} = useSession()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAll = useCloseAllActiveElements()
const {signinDialogControl} = useGlobalDialogsControlContext()
return React.useCallback(
(fn: () => void) => {
@@ -587,13 +543,21 @@ export function useRequireAuth() {
fn()
} else {
closeAll()
signinDialogControl.open()
setShowLoggedOut(true)
}
},
[hasSession, signinDialogControl, closeAll],
[hasSession, setShowLoggedOut, closeAll],
)
}
export function useAgent() {
return React.useMemo(() => ({getAgent: __getAgent}), [])
const {currentAgent} = useSession()
return React.useMemo(
() => ({
getAgent() {
return currentAgent
},
}),
[currentAgent],
)
}
+38 -2
View File
@@ -1,13 +1,37 @@
import {BskyAgent} from '@atproto/api'
import {LogEvents} from '#/lib/statsig/statsig'
import {PersistedAccount} from '#/state/persisted'
/**
* Alias for `PersistedAccount` from persisted storage.
*/
export type SessionAccount = PersistedAccount
/**
* Subset of `SessionAccount` that excludes tokens.
*/
export type CurrentAccount = Omit<SessionAccount, 'accessJwt' | 'refreshJwt'>
/**
* Context shape returned from `useSession()`
*/
export type SessionStateContext = {
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
currentAgent: BskyAgent
isInitialLoad: boolean
isSwitchingAccounts: boolean
hasSession: boolean
accounts: SessionAccount[]
/**
* Contains the full account object persisted to storage, minus access
* tokens.
*/
currentAccount: CurrentAccount | undefined
}
/**
* Context shape returned from `useSessionApi()`
*/
export type SessionApiContext = {
createAccount: (props: {
service: string
@@ -44,7 +68,19 @@ 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>
/**
* Refreshes the BskyAgent's session and derive a fresh `currentAccount`
*/
refreshSession: () => void
/**
* @deprecated Use `refreshSession` instead.
*/
updateCurrentAccount: (
account: Partial<
Pick<
+14 -2
View File
@@ -43,6 +43,20 @@ export function agentToSessionAccount(
}
}
export function sessionAccountToAgentSession(
account: SessionAccount,
): BskyAgent['session'] {
return {
refreshJwt: account.refreshJwt || '',
accessJwt: account.accessJwt || '',
did: account.did,
handle: account.handle,
email: account.email,
emailConfirmed: account.emailConfirmed,
emailAuthFactor: account.emailAuthFactor,
}
}
export function configureModerationForGuest() {
switchToBskyAppLabeler()
}
@@ -122,7 +136,6 @@ export async function createAgentAndLogin({
return {
agent,
account,
fetchingGates,
}
}
@@ -171,7 +184,6 @@ export async function createAgentAndCreateAccount({
return {
agent,
account,
fetchingGates,
}
}
+195
View File
@@ -0,0 +1,195 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyRichtextFacet,
} from '@atproto/api'
import {ValidationResult} from '@atproto/lexicon'
export interface Message {
id?: string
text: string
/** Annotations of text (mentions, URLs, hashtags, etc) */
facets?: AppBskyRichtextFacet.Main[]
embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isMessage(v: unknown): v is Message {
return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message'
}
export function validateMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface MessageView {
id: string
rev: string
text: string
/** Annotations of text (mentions, URLs, hashtags, etc) */
facets?: AppBskyRichtextFacet.Main[]
embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
sender?: MessageViewSender
sentAt: string
[k: string]: unknown
}
export function isMessageView(v: unknown): v is MessageView {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView'
)
}
export function validateMessageView(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface DeletedMessage {
id: string
rev?: string
sender?: MessageViewSender
sentAt: string
[k: string]: unknown
}
export function isDeletedMessage(v: unknown): v is DeletedMessage {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage'
)
}
export function validateDeletedMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface MessageViewSender {
did: string
[k: string]: unknown
}
export function isMessageViewSender(v: unknown): v is MessageViewSender {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#messageViewSender'
)
}
export function validateMessageViewSender(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface ChatView {
id: string
rev: string
members: AppBskyActorDefs.ProfileViewBasic[]
lastMessage?:
| MessageView
| DeletedMessage
| {$type: string; [k: string]: unknown}
unreadCount: number
[k: string]: unknown
}
export function isChatView(v: unknown): v is ChatView {
return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView'
}
export function validateChatView(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export type IncomingMessageSetting =
| 'all'
| 'none'
| 'following'
| (string & {})
export interface LogBeginChat {
rev: string
chatId: string
[k: string]: unknown
}
export function isLogBeginChat(v: unknown): v is LogBeginChat {
return (
isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat'
)
}
export function validateLogBeginChat(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface LogCreateMessage {
rev: string
chatId: string
message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isLogCreateMessage(v: unknown): v is LogCreateMessage {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#logCreateMessage'
)
}
export function validateLogCreateMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export interface LogDeleteMessage {
rev: string
chatId: string
message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
[k: string]: unknown
}
export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.defs#logDeleteMessage'
)
}
export function validateLogDeleteMessage(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}
+31
View File
@@ -0,0 +1,31 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
chatId: string
messageId: string
[k: string]: unknown
}
export type OutputSchema = TempDmDefs.DeletedMessage
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
chatId: string
}
export type InputSchema = undefined
export interface OutputSchema {
chat: TempDmDefs.ChatView
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
members: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
chat: TempDmDefs.ChatView
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+36
View File
@@ -0,0 +1,36 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
logs: (
| TempDmDefs.LogBeginChat
| TempDmDefs.LogCreateMessage
| TempDmDefs.LogDeleteMessage
| {$type: string; [k: string]: unknown}
)[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+37
View File
@@ -0,0 +1,37 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
chatId: string
limit?: number
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
messages: (
| TempDmDefs.MessageView
| TempDmDefs.DeletedMessage
| {$type: string; [k: string]: unknown}
)[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+28
View File
@@ -0,0 +1,28 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export type InputSchema = undefined
export interface OutputSchema {
allowIncoming: TempDmDefs.IncomingMessageSetting
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
export interface QueryParams {}
export interface InputSchema {
chatId: string
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+32
View File
@@ -0,0 +1,32 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {
limit?: number
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
chats: TempDmDefs.ChatView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+30
View File
@@ -0,0 +1,30 @@
import {Headers, XRPCError} from '@atproto/xrpc'
export interface QueryParams {}
export interface InputSchema {
chatId: string
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+31
View File
@@ -0,0 +1,31 @@
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
chatId: string
message: TempDmDefs.Message
[k: string]: unknown
}
export type OutputSchema = TempDmDefs.MessageView
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
+66
View File
@@ -0,0 +1,66 @@
import {ValidationResult} from '@atproto/lexicon'
import {Headers, XRPCError} from '@atproto/xrpc'
import * as TempDmDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
items: BatchItem[]
[k: string]: unknown
}
export interface OutputSchema {
items: TempDmDefs.MessageView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
export interface BatchItem {
chatId: string
message: TempDmDefs.Message
[k: string]: unknown
}
export function isBatchItem(v: unknown): v is BatchItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'temp.dm.sendMessageBatch#batchItem'
)
}
export function validateBatchItem(v: unknown): ValidationResult {
return {
success: true,
value: v,
}
}
export function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null
}
export function hasProp<K extends PropertyKey>(
data: object,
prop: K,
): data is Record<K, unknown> {
return prop in data
}

Some files were not shown because too many files have changed in this diff Show More