Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4904c5067a | |||
| 7d2b711b88 |
@@ -4,5 +4,3 @@ SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_ENV=development
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: build-and-push-bskyweb-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 3p-moderators
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
@@ -54,6 +54,3 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
|
||||
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
|
||||
|
||||
@@ -57,8 +57,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -65,8 +65,6 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -144,8 +144,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
@@ -224,8 +222,6 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
@@ -287,8 +283,6 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -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
@@ -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
@@ -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
@@ -48,7 +48,7 @@ import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesScreen} from './screens/Messages/List'
|
||||
import {MessagesListScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
@@ -462,8 +462,8 @@ function MessagesTabNavigator() {
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
|
||||
options={{title: title(msg`Notifications`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
@@ -570,7 +570,7 @@ const LINKING = {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
if (name === 'Messages') {
|
||||
return buildStateObject('MessagesTab', 'Messages', params)
|
||||
return buildStateObject('MessagesTab', 'MessagesList', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
|
||||
@@ -16,14 +16,12 @@ export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
pendingDid,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
pendingDid: string | null
|
||||
}) {
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -33,7 +31,6 @@ export function AccountList({
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents={pendingDid ? 'none' : 'auto'}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
@@ -46,7 +43,6 @@ export function AccountList({
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
@@ -54,7 +50,7 @@ export function AccountList({
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
@@ -63,7 +59,8 @@ export function AccountList({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -87,12 +84,10 @@ function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
isPendingAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
isPendingAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -120,7 +115,7 @@ function AccountItem({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
|
||||
@@ -64,7 +64,7 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -43,9 +43,7 @@ export function Outer({
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
{children}
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
|
||||
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner />
|
||||
<MutedWordsInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner() {
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
@@ -54,7 +54,6 @@ export function SwitchAccountDialog({
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
@@ -4,17 +4,7 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
|
||||
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
|
||||
// for Statsig reporting and shouldn't be used to identify a specific bundle.
|
||||
export const BUNDLE_DATE =
|
||||
IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
|
||||
IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
|
||||
IS_DEV ? 'development' : UPDATES_CHANNEL
|
||||
})`
|
||||
|
||||
@@ -1,17 +1,2 @@
|
||||
import {version} from '../../package.json'
|
||||
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
|
||||
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
|
||||
// for Statsig reporting and shouldn't be used to identify a specific bundle.
|
||||
export const BUNDLE_DATE = IS_DEV
|
||||
? 0
|
||||
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = version
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
|
||||
|
||||
@@ -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]'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -8,14 +8,12 @@ import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {initSession} = useSessionApi()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
@@ -24,12 +22,8 @@ export function useAccountSwitcher() {
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
if (account.accessJwt) {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
@@ -39,9 +33,10 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
await selectAccount(account, logContext)
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} else {
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
Toast.show(
|
||||
@@ -53,12 +48,14 @@ export function useAccountSwitcher() {
|
||||
logger.error(`switch account: selectAccount failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
clearCurrentAccount() // back user out to login
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Sorry! We need you to enter your password.`))
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
[_, track, initSession, requestSwitchToAccount, pendingDid],
|
||||
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
return {onPressSwitchAccount}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ export function useNavigationTabState() {
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
|
||||
}
|
||||
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtSearch &&
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
|
||||
export function useNavigationTabState() {
|
||||
@@ -10,7 +9,6 @@ export function useNavigationTabState() {
|
||||
isAtSearch: currentRoute === 'Search',
|
||||
isAtNotifications: currentRoute === 'Notifications',
|
||||
isAtMyProfile: currentRoute === 'MyProfile',
|
||||
isAtMessages: currentRoute === 'Messages',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesTab: undefined
|
||||
Messages: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow_v2'
|
||||
| 'disable_min_shell_on_foregrounding_v3'
|
||||
| 'disable_min_shell_on_foregrounding_v2'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'dms'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'start_session_with_following_v2'
|
||||
| 'test_gate_1'
|
||||
|
||||
@@ -9,5 +9,4 @@ export const DebugContext = {
|
||||
// e.g. composer: 'composer'
|
||||
session: 'session',
|
||||
notifications: 'notifications',
|
||||
convo: 'convo',
|
||||
} as const
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const router = new Router({
|
||||
CommunityGuidelines: '/support/community-guidelines',
|
||||
CopyrightPolicy: '/support/copyright',
|
||||
Hashtag: '/hashtag/:tag',
|
||||
Messages: '/messages',
|
||||
MessagesList: '/messages',
|
||||
MessagesSettings: '/messages/settings',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ export const ChooseAccountForm = ({
|
||||
onSelectAccount: (account?: SessionAccount) => void
|
||||
onPressBack: () => void
|
||||
}) => {
|
||||
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -35,48 +34,33 @@ export const ChooseAccountForm = ({
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
if (!account.accessJwt) {
|
||||
// Move to login form.
|
||||
if (account.accessJwt) {
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
} else {
|
||||
try {
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
onSelectAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSelectAccount(account)
|
||||
return
|
||||
}
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
// Move to login form.
|
||||
onSelectAccount(account)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
currentAccount,
|
||||
track,
|
||||
initSession,
|
||||
pendingDid,
|
||||
onSelectAccount,
|
||||
setShowLoggedOut,
|
||||
_,
|
||||
],
|
||||
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -90,7 +74,6 @@ export const ChooseAccountForm = ({
|
||||
<AccountList
|
||||
onSelectAccount={onSelect}
|
||||
onSelectOther={() => onSelectAccount()}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_row]}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} · {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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}>
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user