Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey b1a8160d0a Update persisted schema for new source of truth, implement in existing session
(cherry picked from commit b1e5f12bae)
2024-04-26 11:44:47 -05:00
188 changed files with 11689 additions and 17331 deletions
-2
View File
@@ -4,5 +4,3 @@ SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
EXPO_PUBLIC_BUNDLE_DATE=0
@@ -1,9 +1,9 @@
name: build-and-push-bskyweb-aws
on:
workflow_dispatch:
push:
branches:
- main
- 3p-moderators
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -54,6 +54,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
@@ -57,8 +57,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
-2
View File
@@ -65,8 +65,6 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -144,8 +144,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
@@ -224,8 +222,6 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -287,8 +283,6 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
+4 -4
View File
@@ -20,8 +20,8 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Check
run: cd bskyweb/ && make check
- name: Build (binary)
@@ -37,7 +37,7 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Lint
run: cd bskyweb/ && make lint
-1
View File
@@ -40,7 +40,6 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
RUN touch bskyweb/static/media/empty.txt
#
# Generate the embedr Go binary.
+2 -4
View File
@@ -1,9 +1,8 @@
/* eslint-env detox/detox */
import {beforeAll, describe, it} from '@jest/globals'
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {createServer, loginAsAlice, loginAsBob, openApp, sleep} from '../util'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Curate lists', () => {
beforeAll(async () => {
@@ -116,7 +115,6 @@ describe('Curate lists', () => {
await element(by.text('About')).tap()
await element(by.id('addUserBtn')).tap()
await expect(element(by.id('listAddUserModal'))).toBeVisible()
await element(by.id('searchInput')).typeText('b')
await waitFor(element(by.id('user-bob.test-addBtn')))
.toBeVisible()
.withTimeout(5000)
-6
View File
@@ -396,7 +396,6 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
@@ -505,11 +504,6 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
undefined,
undefined,
+3 -4
View File
@@ -1,10 +1,10 @@
import {describe, expect, it} from '@jest/globals'
import {it, describe, expect} from '@jest/globals'
import {
isPossiblyAUrl,
isTrustedUrl,
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
isTrustedUrl,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
@@ -170,7 +170,6 @@ describe('isTrustedUrl', () => {
['https://google.com', false],
['https://docs.google.com', false],
['https://google.com/#', false],
['https://blueskywebxzendesk.com', false],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
-3
View File
@@ -64,8 +64,6 @@ module.exports = function (config) {
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
splash: SPLASH_CONFIG,
// hsl(211, 99%, 53%), same as palette.default.brandText
primaryColor: '#1083fe',
ios: {
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
@@ -182,7 +180,6 @@ module.exports = function (config) {
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean),
@@ -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="M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 303 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

@@ -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="M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 367 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

-2
View File
@@ -10,8 +10,6 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
static/media/*.png
static/media/empty.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
+1 -3
View File
@@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error {
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
@@ -200,8 +200,6 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
View File
+3 -6
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.81.0",
"version": "1.79.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,11 +50,10 @@
"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",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0",
@@ -169,7 +168,6 @@
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.11.7",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -185,7 +183,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",
-28
View File
@@ -1,28 +0,0 @@
diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js
index 32d7798..9d688b7 100644
--- a/node_modules/@atproto/lexicon/dist/validators/complex.js
+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js
@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) {
if (value[key] === null && nullableProps.has(key)) {
continue;
}
- const propDef = def.properties[key];
+ const propDef = def.properties[key]
+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
+ // Fast path for non-required undefined props.
+ if (
+ propDef.type === 'integer' ||
+ propDef.type === 'boolean' ||
+ propDef.type === 'string'
+ ) {
+ if (typeof propDef.default === 'undefined') {
+ continue
+ }
+ } else {
+ // Other types have no defaults.
+ continue
+ }
+ }
const propPath = `${path}/${key}`;
const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]);
const propValue = validated.success ? validated.value : value[key];
@@ -1,25 +0,0 @@
/**
* @file Set accent color to primaryColor from app.config.js.
* This way we get a sane default color for spinners, text inputs, etc.
*/
const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
return withAndroidStyles(appConfig, function (decoratedAppConfig) {
try {
decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue(
decoratedAppConfig.modResults,
{
add: true,
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
name: 'colorAccent',
value: '@color/colorPrimary',
},
)
} catch (e) {
console.error(`withAndroidStylesAccentColorPlugin failed`, e)
}
return decoratedAppConfig
})
}
+37 -56
View File
@@ -15,11 +15,9 @@ 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 {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query'
@@ -34,7 +32,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 +51,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 +60,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>
)
+30 -58
View File
@@ -4,15 +4,11 @@ import 'view/icons'
import React, {useEffect, useState} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
@@ -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>
)
}
-50
View File
@@ -25,7 +25,6 @@ import {
FeedsTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
@@ -47,9 +46,6 @@ import {init as initAnalytics} from './lib/analytics/analytics'
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 {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
@@ -96,8 +92,6 @@ const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const MessagesTab =
createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
@@ -296,16 +290,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
<Stack.Screen
name="MessagesConversation"
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Messaging settings`), requireAuth: true}}
/>
</>
)
}
@@ -339,10 +323,6 @@ function TabsNavigator() {
name="MyProfileTab"
getComponent={() => MyProfileTabNavigator}
/>
<Tab.Screen
name="MessagesTab"
getComponent={() => MessagesTabNavigator}
/>
</Tab.Navigator>
)
}
@@ -449,28 +429,6 @@ function MyProfileTabNavigator() {
)
}
function MessagesTabNavigator() {
const pal = usePalette('default')
return (
<MessagesTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{requireAuth: true}}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
</MessagesTab.Navigator>
)
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
@@ -511,11 +469,6 @@ const FlatNavigator = () => {
getComponent={() => NotificationsScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
@@ -569,9 +522,6 @@ const LINKING = {
if (name === 'Home') {
return buildStateObject('HomeTab', 'Home', params)
}
if (name === 'Messages') {
return buildStateObject('MessagesTab', 'Messages', params)
}
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
return buildStateObject('HomeTab', name, params, [
{
+5 -10
View File
@@ -16,14 +16,12 @@ export function AccountList({
onSelectAccount,
onSelectOther,
otherLabel,
pendingDid,
}: {
onSelectAccount: (account: SessionAccount) => void
onSelectOther: () => void
otherLabel?: string
pendingDid: string | null
}) {
const {currentAccount, accounts} = useSession()
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
const t = useTheme()
const {_} = useLingui()
@@ -33,7 +31,6 @@ export function AccountList({
return (
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_md,
a.overflow_hidden,
@@ -46,7 +43,6 @@ export function AccountList({
account={account}
onSelect={onSelectAccount}
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
</React.Fragment>
@@ -54,7 +50,7 @@ export function AccountList({
<Button
testID="chooseAddAccountBtn"
style={[a.flex_1]}
onPress={pendingDid ? undefined : onPressAddAccount}
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
label={_(msg`Login to account that is not listed`)}>
{({hovered, pressed}) => (
<View
@@ -63,7 +59,8 @@ export function AccountList({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed) && t.atoms.bg_contrast_25,
(hovered || pressed || isSwitchingAccounts) &&
t.atoms.bg_contrast_25,
]}>
<Text
style={[
@@ -87,12 +84,10 @@ function AccountItem({
account,
onSelect,
isCurrentAccount,
isPendingAccount,
}: {
account: SessionAccount
onSelect: (account: SessionAccount) => void
isCurrentAccount: boolean
isPendingAccount: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -120,7 +115,7 @@ function AccountItem({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
+1 -1
View File
@@ -64,7 +64,7 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress'
'disabled' | 'onPress' | 'testID'
> &
AccessibilityProps &
VariantProps & {
-6
View File
@@ -152,12 +152,6 @@ export function Outer({
[open, close],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
+12 -16
View File
@@ -17,14 +17,12 @@ export function Error({
message,
onRetry,
onGoBack: onGoBackProp,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
@@ -72,7 +70,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>
@@ -91,19 +89,17 @@ export function Error({
</ButtonText>
</Button>
)}
{!hideBackButton && (
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
)}
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
</View>
</CenteredView>
)
+1 -5
View File
@@ -3,12 +3,10 @@ import {GestureResponderEvent} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
isBskyDownloadUrl,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
@@ -127,9 +125,7 @@ export function useLink({
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
const shouldOpenInNewTab = isMetaKey || isMiddleClick
if (isBskyDownloadUrl(href)) {
shareUrl(BSKY_DOWNLOAD_URL)
} else if (
if (
shouldOpenInNewTab ||
href.startsWith('http') ||
href.startsWith('mailto')
-4
View File
@@ -134,7 +134,6 @@ let ListMaybePlaceholder = ({
emptyType = 'page',
onRetry,
onGoBack,
hideBackButton,
sideBorders,
}: {
isLoading: boolean
@@ -147,7 +146,6 @@ let ListMaybePlaceholder = ({
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
onGoBack?: () => void
hideBackButton?: boolean
sideBorders?: boolean
}): React.ReactNode => {
const t = useTheme()
@@ -181,7 +179,6 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
}
@@ -201,7 +198,6 @@ let ListMaybePlaceholder = ({
}
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)
+12 -14
View File
@@ -1,29 +1,27 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
ItemIconProps,
ItemProps,
ItemTextProps,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from 'platform/detection'
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
export function useMemoControlContext() {
return React.useContext(Context)
+15 -26
View File
@@ -1,26 +1,27 @@
/* eslint-disable react/prop-types */
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {atoms as a, flatten, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Context} from '#/components/Menu/context'
import {atoms as a, useTheme, flatten, web} from '#/alf'
import {Text} from '#/components/Typography'
import {
ContextType,
GroupProps,
ItemIconProps,
ItemProps,
ItemTextProps,
RadixPassThroughTriggerProps,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
RadixPassThroughTriggerProps,
} from '#/components/Menu/types'
import {Context} from '#/components/Menu/context'
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId()
@@ -134,22 +135,10 @@ export function Trigger({children, label}: TriggerProps) {
},
props: {
...props,
// No-op override to prevent false positive that interprets mobile scroll as a tap.
// This requires the custom onPress handler below to compensate.
// https://github.com/radix-ui/primitives/issues/1912
onPointerDown: undefined,
onPress: () => {
if (window.event instanceof KeyboardEvent) {
// The onPointerDown hack above is not relevant to this press, so don't do anything.
return
}
// Compensate for the disabled onPointerDown above by triggering it manually.
if (control.isOpen) {
control.close()
} else {
control.open()
}
},
// disable on web, use `onPress`
onPointerDown: () => false,
onPress: () =>
control.isOpen ? control.close() : control.open(),
onFocus: onFocus,
onBlur: onBlur,
onMouseEnter,
+3 -10
View File
@@ -34,17 +34,10 @@ export function createPortalGroup() {
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)
return (
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
+6 -14
View File
@@ -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'
@@ -72,10 +72,10 @@ type Action =
| 'unhovered-long-enough'
| 'finished-animating-hide'
const SHOW_DELAY = 500
const SHOW_DELAY = 400
const SHOW_DURATION = 300
const HIDE_DELAY = 150
const HIDE_DURATION = 200
const HIDE_DURATION = 150
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const {refs, floatingStyles} = useFloating({
@@ -244,20 +244,12 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
}
}, [prefetchProfileQuery, props.did])
const didFireHover = React.useRef(false)
const onPointerMoveTarget = React.useCallback(() => {
const onPointerEnterTarget = React.useCallback(() => {
prefetchIfNeeded()
// Conceptually we want something like onPointerEnter,
// but we want to ignore entering only due to scrolling.
// So instead we hover on the first onPointerMove.
if (!didFireHover.current) {
didFireHover.current = true
dispatch('hovered-target')
}
dispatch('hovered-target')
}, [prefetchIfNeeded])
const onPointerLeaveTarget = React.useCallback(() => {
didFireHover.current = false
dispatch('unhovered-target')
}, [])
@@ -288,7 +280,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
return (
<div
ref={refs.setReference}
onPointerMove={onPointerMoveTarget}
onPointerEnter={onPointerEnterTarget}
onPointerLeave={onPointerLeaveTarget}
onMouseUp={onPress}
style={{
+1 -3
View File
@@ -43,9 +43,7 @@ export function Outer({
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
{children}
</Dialog.ScrollableInner>
</Context.Provider>
+2 -2
View File
@@ -1,5 +1,5 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {TextInput, View} from 'react-native'
import {Keyboard, TextInput, View} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
@@ -216,7 +216,7 @@ function GifList({
keyExtractor={(item: Gif) => item.id}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
keyboardDismissMode="on-drag"
onScrollBeginDrag={() => Keyboard.dismiss()}
ListFooterComponent={
hasData ? (
<ListFooter
+2 -2
View File
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner />
<MutedWordsInner control={control} />
</Dialog.Outer>
)
}
function MutedWordsInner() {
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
+1 -2
View File
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelectAccount = useCallback(
@@ -54,7 +54,6 @@ export function SwitchAccountDialog({
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</View>
</Dialog.ScrollableInner>
-83
View File
@@ -1,83 +0,0 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable, View} from 'react-native'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {useHaptics} from 'lib/haptics'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animationDidComplete = useSharedValue(false)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
// function
const open = useCallback(() => {
Keyboard.dismiss()
menuControl.open()
}, [menuControl])
const shrink = useCallback(() => {
'worklet'
cancelAnimation(scale)
scale.value = withTiming(1, {duration: 200}, () => {
animationDidComplete.value = false
})
}, [animationDidComplete, scale])
const grow = React.useCallback(() => {
'worklet'
scale.value = withTiming(1.05, {duration: 750}, finished => {
if (!finished) return
animationDidComplete.value = true
runOnJS(playHaptic)()
runOnJS(open)()
shrink()
})
}, [scale, animationDidComplete, playHaptic, shrink, open])
return (
<View
style={[
{
maxWidth: '65%',
},
isFromSelf ? a.self_end : a.self_start,
]}>
<AnimatedPressable
style={animatedStyle}
unstable_pressDelay={200}
onPressIn={grow}
onTouchEnd={shrink}>
{children}
</AnimatedPressable>
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
</View>
)
}
-86
View File
@@ -1,86 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const [showActions, setShowActions] = React.useState(false)
const onMouseEnter = React.useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = React.useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
return (
<View
// @ts-expect-error web only
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_xl,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
<View
style={{
maxWidth: '65%',
}}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
</View>
)
}
-184
View File
@@ -1,184 +0,0 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useMuteConvo,
useUnmuteConvo,
} from '#/state/queries/messages/mute-conversation'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
let ConvoMenu = ({
convo,
profile,
onUpdateConvo,
control,
hideTrigger,
currentScreen,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: AppBskyActorDefs.ProfileViewBasic
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
control?: Menu.MenuControlProps
hideTrigger?: boolean
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat muted`))
},
onError: () => {
Toast.show(_(msg`Could not mute chat`))
},
})
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat unmuted`))
},
onError: () => {
Toast.show(_(msg`Could not unmute chat`))
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace('Messages')
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
props.onPress()
}}
style={[
a.p_sm,
a.rounded_sm,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="lg" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute notifications`)}
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute notifications</Trans>
) : (
<Trans>Mute notifications</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
{/* TODO(samuel): implement these */}
<Menu.Group>
<Menu.Item
label={_(msg`Block account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Block account</Trans>
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
/>
</Menu.Item>
<Menu.Item
label={_(msg`Report account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Report account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={leaveConvoControl}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={() => leaveConvo()}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
-198
View File
@@ -1,198 +0,0 @@
import React, {useCallback, useMemo, useRef} from 'react'
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography'
export let MessageItem = ({
item,
next,
pending,
}: {
item: ChatBskyConvoDefs.MessageView
next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
pending?: boolean
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const isFromSelf = item.sender?.did === currentAccount?.did
const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(next) &&
next.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => {
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
if (item.id.length > 13) {
return false
}
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 3 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 3 minutes
return diff > 3 * 60 * 1000
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
lastInGroupRef.current = isLastInGroup
}
const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
return (
<View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? pending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
pending && t.name !== 'light' && {color: t.palette.primary_300},
]}>
{item.text}
</Text>
</View>
</ActionsWrapper>
<MessageItemMetadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
/>
</View>
)
}
MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message,
isLastInGroup,
style,
}: {
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// if under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// if in the last day
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_2xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
MessageItemMetadata = React.memo(MessageItemMetadata)
function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
-141
View File
@@ -1,141 +0,0 @@
import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useChat} from 'state/messages'
import {ConvoStatus} from 'state/messages/convo'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
export let MessageMenu = ({
message,
control,
hideTrigger,
triggerOpacity,
}: {
hideTrigger?: boolean
triggerOpacity?: number
onTriggerPress?: () => void
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const chat = useChat()
const deleteControl = usePromptControl()
const retryDeleteControl = usePromptControl()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyPostText = React.useCallback(() => {
// use when we have rich text
// const str = richTextToString(richText, true)
Clipboard.setStringAsync(message.text)
Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text])
const onDelete = React.useCallback(() => {
if (chat.status !== ConvoStatus.Ready) return
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
chat
.deleteMessage(message.id)
.then(() => Toast.show(_(msg`Message deleted`)))
.catch(() => retryDeleteControl.open())
}, [_, chat, message.id, retryDeleteControl])
const onReport = React.useCallback(() => {
// TODO report the message
}, [])
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="sm" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={onReport}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
<Prompt.Basic
control={retryDeleteControl}
title={_(msg`Failed to delete message`)}
description={_(
msg`An error occurred while trying to delete the message. Please try again.`,
)}
confirmButtonCta={_(msg`Retry`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
-255
View File
@@ -1,255 +0,0 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Keyboard, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
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 {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
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 {Button} from '../Button'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
import {ListMaybePlaceholder} from '../Lists'
import {Text} from '../Typography'
export function NewChat({
control,
onNewChat,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Handle />
<SearchablePeopleList onCreateChat={onCreateChat} />
</Dialog.Outer>
</>
)
}
function SearchablePeopleList({
onCreateChat,
}: {
onCreateChat: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const [searchText, setSearchText] = useState('')
const {
data: actorAutocompleteData,
isFetching,
isError,
refetch,
} = useActorAutocompleteQuery(searchText, true)
const renderItem = useCallback(
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
if (!moderationOpts) return null
const moderation = moderateProfile(profile, moderationOpts)
return (
<Button
label={profile.displayName || sanitizeHandle(profile.handle)}
onPress={() => onCreateChat(profile.did)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.px_md,
a.py_sm,
a.gap_md,
a.align_center,
a.flex_row,
a.rounded_sm,
pressed
? t.atoms.bg_contrast_25
: hovered
? t.atoms.bg_contrast_50
: t.atoms.bg,
]}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={{flex: 1}}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text style={t.atoms.text_contrast_high} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
)}
</Button>
)
},
[
moderationOpts,
onCreateChat,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
t.atoms.bg,
t.atoms.text,
t.atoms.text_contrast_high,
],
)
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<Dialog.Close />
<Text
style={[
a.text_2xl,
a.font_bold,
a.leading_tight,
a.pb_lg,
web(a.pt_lg),
]}>
<Trans>Start a new chat</Trans>
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<Dialog.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _, control, searchText])
return (
<Dialog.InnerFlatList
ref={listRef}
data={actorAutocompleteData}
renderItem={renderItem}
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.`)
}
/>
)
)}
</>
}
stickyHeaderIndices={[0]}
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
onScrollBeginDrag={() => Keyboard.dismiss()}
/>
)
}
-17
View File
@@ -1,17 +0,0 @@
import {useCallback, useRef} from 'react'
import {useFocusEffect} from '@react-navigation/native'
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
const firstTimeRef = useRef(true)
useFocusEffect(
useCallback(() => {
if (firstTimeRef.current) {
firstTimeRef.current = false
return
}
refetch()
}, [refetch]),
)
}
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
})
-4
View File
@@ -3,7 +3,3 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
})
export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
})
-4
View File
@@ -3,7 +3,3 @@ import {createSinglePathSVG} from './TEMPLATE'
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
})
export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z',
})
-6
View File
@@ -1,6 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsSliderVertical_Stroke2_Corner0_Rounded =
createSinglePathSVG({
path: 'M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z',
})
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'
import Svg, {Path} from 'react-native-svg'
import {Props, useCommonSVGProps} from '#/components/icons/common'
import {useCommonSVGProps, Props} from '#/components/icons/common'
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
function LogoImpl(props: Props, ref) {
+7 -16
View File
@@ -1,27 +1,25 @@
import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {Trans, msg} from '@lingui/macro'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {addStyle} from 'lib/styles'
import {precacheProfile} from 'state/queries/profile'
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
import {Link} from '#/view/com/util/Link'
import {atoms as a, useTheme} from '#/alf'
import {useTheme, atoms as a} from '#/alf'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
import {Link} from '#/view/com/util/Link'
interface Props extends ComponentProps<typeof Link> {
iconSize: number
iconStyles: StyleProp<ViewStyle>
modui: ModerationUI
profile: AppBskyActorDefs.ProfileViewBasic
}
export function PostHider({
@@ -32,10 +30,8 @@ export function PostHider({
children,
iconSize,
iconStyles,
profile,
...props
}: Props) {
const queryClient = useQueryClient()
const t = useTheme()
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
@@ -43,10 +39,6 @@ export function PostHider({
const blur = modui.blurs[0]
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, profile)
}, [queryClient, profile])
if (!blur) {
return (
<Link
@@ -54,7 +46,6 @@ export function PostHider({
style={style}
href={href}
accessible={false}
onBeforePress={onBeforePress}
{...props}>
{children}
</Link>
+1 -4
View File
@@ -5,7 +5,6 @@ const ScrollContext = createContext<ScrollHandlers<any>>({
onBeginDrag: undefined,
onEndDrag: undefined,
onScroll: undefined,
onMomentumEnd: undefined,
})
export function useScrollHandlers(): ScrollHandlers<any> {
@@ -21,16 +20,14 @@ export function ScrollProvider({
onBeginDrag,
onEndDrag,
onScroll,
onMomentumEnd,
}: ProviderProps) {
const handlers = useMemo(
() => ({
onBeginDrag,
onEndDrag,
onScroll,
onMomentumEnd,
}),
[onBeginDrag, onEndDrag, onScroll, onMomentumEnd],
[onBeginDrag, onEndDrag, onScroll],
)
return (
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
-1
View File
@@ -76,7 +76,6 @@ export type TrackPropertiesMap = {
'MobileShell:SearchButtonPressed': {}
'MobileShell:NotificationsButtonPressed': {}
'MobileShell:FeedsButtonPressed': {}
'MobileShell:MessagesButtonPressed': {}
// NOTIFICATIONS events
'Notificatons:OpenApp': {}
// LISTS events
-10
View File
@@ -7,25 +7,20 @@ import {
import {getContentLanguages} from '#/state/preferences/languages'
import {FeedAPI, FeedAPIResponse} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
export class CustomFeedAPI implements FeedAPI {
getAgent: () => BskyAgent
params: GetCustomFeed.QueryParams
userInterests?: string
constructor({
getAgent,
feedParams,
userInterests,
}: {
getAgent: () => BskyAgent
feedParams: GetCustomFeed.QueryParams
userInterests?: string
}) {
this.getAgent = getAgent
this.params = feedParams
this.userInterests = userInterests
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
@@ -49,8 +44,6 @@ export class CustomFeedAPI implements FeedAPI {
}): Promise<FeedAPIResponse> {
const contentLangs = getContentLanguages().join(',')
const agent = this.getAgent()
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
const res = agent.session
? await this.getAgent().app.bsky.feed.getFeed(
{
@@ -60,9 +53,6 @@ export class CustomFeedAPI implements FeedAPI {
},
{
headers: {
...(isBlueskyOwned
? createBskyTopicsHeader(this.userInterests)
: {}),
'Accept-Language': contentLangs,
},
},
+1 -10
View File
@@ -32,22 +32,14 @@ export class HomeFeedAPI implements FeedAPI {
discover: CustomFeedAPI
usingDiscover = false
itemCursor = 0
userInterests?: string
constructor({
userInterests,
getAgent,
}: {
userInterests?: string
getAgent: () => BskyAgent
}) {
constructor({getAgent}: {getAgent: () => BskyAgent}) {
this.getAgent = getAgent
this.following = new FollowingFeedAPI({getAgent})
this.discover = new CustomFeedAPI({
getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
this.userInterests = userInterests
}
reset() {
@@ -55,7 +47,6 @@ export class HomeFeedAPI implements FeedAPI {
this.discover = new CustomFeedAPI({
getAgent: this.getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
userInterests: this.userInterests,
})
this.usingDiscover = false
this.itemCursor = 0
-14
View File
@@ -9,13 +9,11 @@ import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedTunerFn} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
userInterests?: string
getAgent: () => BskyAgent
params: FeedParams
feedTuners: FeedTunerFn[]
@@ -29,17 +27,14 @@ export class MergeFeedAPI implements FeedAPI {
getAgent,
feedParams,
feedTuners,
userInterests,
}: {
getAgent: () => BskyAgent
feedParams: FeedParams
feedTuners: FeedTunerFn[]
userInterests?: string
}) {
this.getAgent = getAgent
this.params = feedParams
this.feedTuners = feedTuners
this.userInterests = userInterests
this.following = new MergeFeedSource_Following({
getAgent: this.getAgent,
feedTuners: this.feedTuners,
@@ -63,7 +58,6 @@ export class MergeFeedAPI implements FeedAPI {
getAgent: this.getAgent,
feedUri,
feedTuners: this.feedTuners,
userInterests: this.userInterests,
}),
),
)
@@ -260,18 +254,15 @@ class MergeFeedSource_Custom extends MergeFeedSource {
getAgent: () => BskyAgent
minDate: Date
feedUri: string
userInterests?: string
constructor({
getAgent,
feedUri,
feedTuners,
userInterests,
}: {
getAgent: () => BskyAgent
feedUri: string
feedTuners: FeedTunerFn[]
userInterests?: string
}) {
super({
getAgent,
@@ -279,7 +270,6 @@ class MergeFeedSource_Custom extends MergeFeedSource {
})
this.getAgent = getAgent
this.feedUri = feedUri
this.userInterests = userInterests
this.sourceInfo = {
$type: 'reasonFeedSource',
uri: feedUri,
@@ -294,7 +284,6 @@ class MergeFeedSource_Custom extends MergeFeedSource {
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const contentLangs = getContentLanguages().join(',')
const isBlueskyOwned = isBlueskyOwnedFeed(this.feedUri)
const res = await this.getAgent().app.bsky.feed.getFeed(
{
cursor,
@@ -303,9 +292,6 @@ class MergeFeedSource_Custom extends MergeFeedSource {
},
{
headers: {
...(isBlueskyOwned
? createBskyTopicsHeader(this.userInterests)
: {}),
'Accept-Language': contentLangs,
},
},
-21
View File
@@ -1,21 +0,0 @@
import {AtUri} from '@atproto/api'
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
export function createBskyTopicsHeader(userInterests?: string) {
return {
'X-Bsky-Topics': userInterests || '',
}
}
export function aggregateUserInterests(
preferences?: UsePreferencesQueryResponse,
) {
return preferences?.interests?.tags?.join(',') || ''
}
export function isBlueskyOwnedFeed(feedUri: string) {
const uri = new AtUri(feedUri)
return BSKY_FEED_OWNER_DIDS.includes(uri.host)
}
+3 -13
View File
@@ -4,17 +4,7 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE =
IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
IS_DEV ? 'development' : UPDATES_CHANNEL
})`
-15
View File
@@ -1,17 +1,2 @@
import {version} from '../../package.json'
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE = IS_DEV
? 0
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = version
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
-1
View File
@@ -10,7 +10,6 @@ const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
-87
View File
@@ -9,90 +9,3 @@ export function dedupArray<T>(arr: T[]): T[] {
const s = new Set(arr)
return [...s]
}
/**
* Taken from @tanstack/query-core utils.ts
* Modified to support Date object comparisons
*
* This function returns `a` if `b` is deeply equal.
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
export function replaceEqualDeep(a: any, b: any): any {
if (a === b) {
return a
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime() ? a : b
}
const array = isPlainArray(a) && isPlainArray(b)
if (array || (isPlainObject(a) && isPlainObject(b))) {
const aItems = array ? a : Object.keys(a)
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
if (
!array &&
a[key] === undefined &&
b[key] === undefined &&
aItems.includes(key)
) {
copy[key] = undefined
equalItems++
} else {
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key] && a[key] !== undefined) {
equalItems++
}
}
}
return aSize === bSize && equalItems === aSize ? a : copy
}
return b
}
export function isPlainArray(value: unknown) {
return Array.isArray(value) && value.length === Object.keys(value).length
}
// Copied from: https://github.com/jonschlinkert/is-plain-object
export function isPlainObject(o: any): o is Object {
if (!hasObjectPrototype(o)) {
return false
}
// If has no constructor
const ctor = o.constructor
if (ctor === undefined) {
return true
}
// If has modified prototype
const prot = ctor.prototype
if (!hasObjectPrototype(prot)) {
return false
}
// If constructor does not have an Object-specific method
if (!prot.hasOwnProperty('isPrototypeOf')) {
return false
}
// Most likely a plain Object
return true
}
function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}
+13 -25
View File
@@ -1,21 +1,15 @@
import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCallback} from 'react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logger} from '#/logger'
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 +18,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,26 +29,24 @@ 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(`Signed in as @${account.handle}`)
}, 100)
} else {
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
_(msg`Please sign in as @${account.handle}`),
`Please sign in as @${account.handle}`,
'circle-exclamation',
)
}
} catch (e: any) {
logger.error(`switch account: selectAccount failed`, {
message: e.message,
})
} finally {
setPendingDid(null)
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
}
},
[_, track, initSession, requestSwitchToAccount, pendingDid],
[track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
)
return {onPressSwitchAccount, pendingDid}
return {onPressSwitchAccount}
}
+1 -5
View File
@@ -1,5 +1,4 @@
import {useNavigationState} from '@react-navigation/native'
import {getTabState, TabState} from 'lib/routes/helpers'
export function useNavigationTabState() {
@@ -11,16 +10,13 @@ export function useNavigationTabState() {
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
}
if (
!res.isAtHome &&
!res.isAtSearch &&
!res.isAtFeeds &&
!res.isAtNotifications &&
!res.isAtMyProfile &&
!res.isAtMessages
!res.isAtMyProfile
) {
// HACK for some reason useNavigationState will give us pre-hydration results
// and not update after, so we force isAtHome if all came back false
@@ -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',
}
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {Route, RouteParams} from './types'
import {RouteParams, Route} from './types'
export class Router {
routes: [string, Route][] = []
-10
View File
@@ -38,8 +38,6 @@ export type CommonNavigatorParams = {
AccessibilitySettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string}
MessagesSettings: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -48,7 +46,6 @@ export type BottomTabNavigatorParams = CommonNavigatorParams & {
FeedsTab: undefined
NotificationsTab: undefined
MyProfileTab: undefined
MessagesTab: undefined
}
export type HomeTabNavigatorParams = CommonNavigatorParams & {
@@ -71,17 +68,12 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
MyProfile: undefined
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: undefined
}
export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string}
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
Messages: undefined
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -95,8 +87,6 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Messages: undefined
}
// NOTE
+2 -2
View File
@@ -1,9 +1,9 @@
export type Gate =
// Keep this alphabetic please.
| 'autoexpand_suggestions_on_profile_follow_v2'
| 'disable_min_shell_on_foregrounding_v3'
| 'disable_min_shell_on_foregrounding_v2'
| 'disable_poll_on_discover_v2'
| 'dms'
| 'hide_vertical_scroll_indicators'
| 'show_follow_back_label_v2'
| 'start_session_with_following_v2'
| 'test_gate_1'
+1 -2
View File
@@ -95,8 +95,7 @@ export function parseEmbedPlayerFromUrl(
if (
urlp.hostname === 'www.youtube.com' ||
urlp.hostname === 'youtube.com' ||
urlp.hostname === 'm.youtube.com' ||
urlp.hostname === 'music.youtube.com'
urlp.hostname === 'm.youtube.com'
) {
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
+6 -14
View File
@@ -1,15 +1,14 @@
import {AtUri} from '@atproto/api'
import psl from 'psl'
import TLDs from 'tlds'
import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
export const BSKY_APP_HOST = 'https://bsky.app'
const BSKY_TRUSTED_HOSTS = [
'bsky\\.app',
'bsky\\.social',
'blueskyweb\\.xyz',
'blueskyweb\\.zendesk\\.com',
'bsky.app',
'bsky.social',
'blueskyweb.xyz',
'blueskyweb.zendesk.com',
...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
]
@@ -146,13 +145,6 @@ export function isBskyListUrl(url: string): boolean {
return false
}
export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) {
return false
}
return url === '/download' || url.startsWith('/download?')
}
export function convertBskyAppUrlIfNeeded(url: string): string {
if (isBskyAppUrl(url)) {
try {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -9,5 +9,4 @@ export const DebugContext = {
// e.g. composer: 'composer'
session: 'session',
notifications: 'notifications',
convo: 'convo',
} as const
-3
View File
@@ -37,7 +37,4 @@ export const router = new Router({
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
Messages: '/messages',
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
})
+17 -42
View File
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
@@ -22,7 +21,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 +33,26 @@ 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 {
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)
}
} 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 +66,6 @@ export const ChooseAccountForm = ({
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
@@ -1,111 +0,0 @@
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 {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from 'lib/haptics'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
export function MessageInput({
onSendMessage,
scrollToEnd,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const [message, setMessage] = React.useState('')
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
const {top: topInset} = useSafeAreaInsets()
const inputRef = React.useRef<TextInput>(null)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
playHaptic()
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],
)
return (
<View style={a.p_sm}>
<View
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>
</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>
)
}
@@ -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,258 +0,0 @@
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 {isIOS} from '#/platform/detection'
import {useChat} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
<View
style={{
height: 50,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
{isLoading && <Loader size="xl" />}
</View>
)
}
function RetryButton({onPress}: {onPress: () => unknown}) {
const {_} = useLingui()
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} />
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList() {
const chat = useChat()
const flatListRef = useRef<FlatList>(null)
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
const isAtBottom = useSharedValue(true)
// This will be used on web to assist in determing if we need to maintain the content offset
const isAtTop = useSharedValue(true)
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
// onStartReached to fire.
const contentHeight = useSharedValue(0)
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false)
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
// Every time the content size changes, that means one of two things is happening:
// 1. New messages are being added from the log or from a message you have sent
// 2. Old messages are being prepended to the top
//
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
//
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
// we will not scroll whenever new items get prepended to the top.
const onContentSizeChange = useCallback(
(_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// previous offset whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasInitiallyScrolled) {
flatListRef.current?.scrollToOffset({
animated: false,
offset: height - contentHeight.value,
})
}
contentHeight.value = height
// This number _must_ be the height of the MaybeLoader component
if (height <= 50 || !isAtBottom.value) {
return
}
flatListRef.current?.scrollToOffset({
animated: hasInitiallyScrolled,
offset: height,
})
isMomentumScrolling.value = true
},
[
contentHeight,
hasInitiallyScrolled,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
],
)
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
const onStartReached = useCallback(() => {
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
chat.fetchMessageHistory()
}
}, [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})
}, [])
useKeyboardHandler({
onMove: () => {
'worklet'
runOnJS(scrollToEndNow)()
},
})
return (
<KeyboardAvoidingView
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
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} />
}
/>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</KeyboardAvoidingView>
)
}
-148
View File
@@ -1,148 +0,0 @@
import React, {useCallback} 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 {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
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 {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversation'
>
export function MessagesConversationScreen({route}: Props) {
const gate = useGate()
const convoId = route.params.conversation
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>
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={chat.recipients[0]} />
<MessagesList />
</CenteredView>
</KeyboardProvider>
)
}
let Header = ({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const chat = useChat()
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('Messages')
} else {
navigation.pop()
}
}, [navigation])
const onUpdateConvo = useCallback(() => {
// TODO eric update muted state
}, [])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
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"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<FontAwesomeIcon
size={18}
icon="angle-left"
style={{
marginTop: 6,
}}
color={t.atoms.text.color}
/>
</TouchableOpacity>
) : (
<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}
</Text>
</View>
{chat.status === ConvoStatus.Ready ? (
<ConvoMenu
convo={chat.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
/>
) : (
<View style={{width: 30}} />
)}
</View>
)
}
Header = React.memo(Header)
-399
View File
@@ -1,399 +0,0 @@
/* eslint-disable react/prop-types */
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 {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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {NewChat} from '#/components/dms/NewChat'
import * as TextField from '#/components/forms/TextField'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useMenuControl} from '#/components/Menu'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen({navigation}: Props) {
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
to="/messages/settings"
accessibilityLabel={_(msg`Message settings`)}
accessibilityHint={_(msg`Opens the message settings page`)}>
<SettingsSlider size="lg" style={t.atoms.text} />
</Link>
)
}, [_, t.atoms.text])
const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
refetch,
} = useListConvos({refetchInterval: 15_000})
useRefreshOnFocus(refetch)
const isError = !!error
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
}
return []
}, [data])
const onRefresh = useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh conversations', {message: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more conversations', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesSettings')
}, [navigation])
const renderItem = useCallback(
({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
return <ChatListItem key={item.id} convo={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>
)
}
return (
<View style={a.flex_1}>
{!gtMobile && (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
<NewChat onNewChat={onNewChat} control={newChatControl} />
<List
data={conversations}
renderItem={renderItem}
keyExtractor={item => item.id}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
ListHeaderComponent={
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderColor: 'transparent'}}
/>
}
onEndReachedThreshold={3}
initialNumToRender={initialNumToRender}
windowSize={11}
// @ts-ignore our .web version only -sfn
desktopFixedHeight
/>
</View>
)
}
function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
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`)
}
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherUser) {
return null
}
return (
<Link
to={`/messages/${convo.id}`}
style={a.flex_1}
onLongPress={isNative ? menuControl.open : undefined}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.flex_1,
a.pl_md,
a.py_sm,
a.gap_md,
a.pr_2xl,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View pointerEvents="none">
<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]}>
{otherUser.displayName || otherUser.handle}
</Text>{' '}
{lastMessageSentAt ? (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle} &middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
) : (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
)}
</Text>
<Text
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
</View>
{convo.unreadCount > 0 && (
<View
style={[
a.flex_0,
a.ml_md,
a.mt_sm,
a.rounded_full,
{
backgroundColor: convo.muted
? t.palette.contrast_200
: t.palette.primary_500,
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>
)
}
function DesktopHeader({
newChatControl,
onNavigateToSettings,
}: {
newChatControl: DialogControlProps
onNavigateToSettings: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
if (!gtMobile) {
return null
}
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_b,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Messages</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<Button
label={_(msg`Message settings`)}
color="secondary"
size="large"
variant="ghost"
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
onPress={onNavigateToSettings}>
<ButtonIcon icon={SettingsSlider} />
</Button>
{gtTablet && (
<Button
label={_(msg`New chat`)}
color="primary"
size="large"
variant="solid"
style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]}
onPress={newChatControl.open}>
<ButtonIcon icon={Plus} position="right" />
<ButtonText>
<Trans>New chat</Trans>
</ButtonText>
</Button>
)}
</View>
</View>
)
}
-24
View File
@@ -1,24 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
export function MessagesSettingsScreen({}: Props) {
const {_} = useLingui()
const gate = useGate()
if (!gate('dms')) return <ClipClopGate />
return (
<View>
<ViewHeader title={_(msg`Settings`)} showOnDesktop />
</View>
)
}

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