Compare commits
90 Commits
ff
...
remove-preload
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eae5dfcc0 | |||
| bd22e0fdcf | |||
| 423d3f455b | |||
| f78126e01a | |||
| 87cb4c105e | |||
| 0625a914bd | |||
| 4678c4353b | |||
| 0658dd053d | |||
| c6d30f671a | |||
| 7d72dfb1cb | |||
| 77e6c75a2c | |||
| af77574b3c | |||
| b1a1ca98e8 | |||
| 3cca5f04d6 | |||
| 7f88ec7ca5 | |||
| 790a9c4fad | |||
| 92f6665f93 | |||
| 2ea9e2c929 | |||
| 4fad18b2fa | |||
| e264dfbb87 | |||
| 032f849092 | |||
| 0a8dc289ff | |||
| 901feba6db | |||
| 2ca4b74955 | |||
| fe82257801 | |||
| e1940983a3 | |||
| 136b0e12ab | |||
| fdb5ffff53 | |||
| b4015d10d1 | |||
| 99f3f10fe7 | |||
| 0b6ace990e | |||
| 2a1dbd2756 | |||
| a9feba4efd | |||
| 5cfe5d4f58 | |||
| c90443b37a | |||
| b34d224207 | |||
| db0b030359 | |||
| c33c3b7d1e | |||
| ae7626ce6e | |||
| bc07019911 | |||
| 594b40c3ae | |||
| 6b615f3720 | |||
| c4160c25a3 | |||
| 7448c8f785 | |||
| 91f8202549 | |||
| 9b7818d358 | |||
| 4862bc2ba8 | |||
| eb55bdf172 | |||
| c223bcdaf7 | |||
| feff55a14a | |||
| 876816675e | |||
| 6a4199febb | |||
| 55f3df5596 | |||
| 19a6bdcc86 | |||
| ce02a41163 | |||
| 4a2d4253e5 | |||
| 85b34418ef | |||
| aeed4786db | |||
| 67b0cdf649 | |||
| 997e621ce3 | |||
| a5511e3c22 | |||
| 051e897a2b | |||
| 1e484c6318 | |||
| cdf7a1957a | |||
| c9cf608f78 | |||
| c13685a0cf | |||
| 0f827c3213 | |||
| a9af83692c | |||
| 6405ad7cba | |||
| b86c3b486f | |||
| 8ba1b10ce0 | |||
| 6da18e3dcf | |||
| 96c5db3e69 | |||
| aca55cb192 | |||
| a20fe4c9a0 | |||
| 1a07e23192 | |||
| 5ec945b762 | |||
| dadf27fd2f | |||
| bff055f618 | |||
| e977c99c96 | |||
| c2062753c5 | |||
| 1d8fdbcc34 | |||
| c303df5bd6 | |||
| cc9727a844 | |||
| e19f882450 | |||
| bf7aee19b8 | |||
| beddddcb57 | |||
| bc956803b8 | |||
| ebd333b331 | |||
| ae26963b45 |
+1
-2
@@ -95,8 +95,7 @@ web-build/
|
||||
/ios/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Firebase (Android) Google services
|
||||
# INCLUDED: google-services.json
|
||||
# INCLUDED: google-services.json
|
||||
|
||||
@@ -4,3 +4,5 @@ SENTRY_AUTH_TOKEN=
|
||||
EXPO_PUBLIC_ENV=development
|
||||
EXPO_PUBLIC_LOG_LEVEL=debug
|
||||
EXPO_PUBLIC_LOG_DEBUG=
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
|
||||
EXPO_PUBLIC_BUNDLE_DATE=0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: build-and-push-bskyweb-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 3p-moderators
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
@@ -54,3 +54,6 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
|
||||
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -57,6 +59,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -65,6 +67,8 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -54,7 +54,9 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: last-successful-commit-hash.txt
|
||||
key: last-successful-deployment-commit-${{ github.ref_name }}
|
||||
key: last-successful-deployment-commit-${{ github.ref_name }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
last-successful-deployment-commit-${{ github.ref_name }}-
|
||||
|
||||
- name: Add the last successful deployment commit to the output
|
||||
id: last-successful-commit
|
||||
@@ -68,7 +70,7 @@ jobs:
|
||||
- name: 🕵️ Get the base commit
|
||||
id: base-commit
|
||||
run: |
|
||||
if [ -z "${{ inputs.channel == 'production' }}" ]; then
|
||||
if ${{ inputs.channel == 'production' }}; then
|
||||
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
|
||||
@@ -144,6 +146,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
@@ -182,6 +186,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -222,6 +228,8 @@ jobs:
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
@@ -251,6 +259,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -283,6 +293,8 @@ jobs:
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -3,7 +3,7 @@ import {RichText} from '@atproto/api'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
|
||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||
import {ago} from '../../src/lib/strings/time'
|
||||
@@ -127,35 +127,6 @@ describe('detectLinkables', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralize', () => {
|
||||
const inputs: [number, string, string?][] = [
|
||||
[1, 'follower'],
|
||||
[1, 'member'],
|
||||
[100, 'post'],
|
||||
[1000, 'repost'],
|
||||
[10000, 'upvote'],
|
||||
[100000, 'other'],
|
||||
[2, 'man', 'men'],
|
||||
]
|
||||
const outputs = [
|
||||
'follower',
|
||||
'member',
|
||||
'posts',
|
||||
'reposts',
|
||||
'upvotes',
|
||||
'others',
|
||||
'men',
|
||||
]
|
||||
|
||||
it('correctly pluralizes a set of words', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = pluralize(...input)
|
||||
expect(output).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeRecordUri', () => {
|
||||
const inputs: [string, string, string][] = [
|
||||
['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
|
||||
|
||||
@@ -91,6 +91,28 @@ module.exports = function (config) {
|
||||
entitlements: {
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyAccessedAPITypes: [
|
||||
{
|
||||
NSPrivacyAccessedAPIType:
|
||||
'NSPrivacyAccessedAPICategoryFileTimestamp',
|
||||
NSPrivacyAccessedAPITypeReasons: ['C617.1', '3B52.1', '0A2A.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace',
|
||||
NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryBootTime',
|
||||
NSPrivacyAccessedAPITypeReasons: ['35F9.1'],
|
||||
},
|
||||
{
|
||||
NSPrivacyAccessedAPIType:
|
||||
'NSPrivacyAccessedAPICategoryUserDefaults',
|
||||
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'light-content',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 402 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 623 B |
@@ -47,6 +47,14 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#preload {
|
||||
width: 100px;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
@@ -262,13 +270,19 @@
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<h1 lang="en">JavaScript Required</h1>
|
||||
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
<div id="root">
|
||||
<div id="preload">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<h1 lang="en">JavaScript Required</h1>
|
||||
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
|
||||
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
|
||||
{% block noscript_extra %}{% endblock %}
|
||||
</noscript>
|
||||
{% endblock -%}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import * as React from 'react'
|
||||
|
||||
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.80.0",
|
||||
"version": "1.81.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -60,6 +60,8 @@
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@formatjs/intl-locale": "^3.4.3",
|
||||
"@formatjs/intl-pluralrules": "^5.2.10",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
@@ -98,6 +100,7 @@
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^18.16.2",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
@@ -109,7 +112,7 @@
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^50.0.8",
|
||||
"expo": "^50.0.17",
|
||||
"expo-application": "^5.8.3",
|
||||
"expo-build-properties": "^0.11.1",
|
||||
"expo-camera": "~14.0.4",
|
||||
@@ -149,6 +152,7 @@
|
||||
"lodash.samplesize": "^4.2.0",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
|
||||
+58
-43
@@ -4,7 +4,6 @@ import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
@@ -16,6 +15,8 @@ import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesEventBusProvider} from '#/state/messages/events'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
@@ -34,6 +35,7 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
@@ -53,8 +55,9 @@ import {listenSessionDropped} from './state/events'
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -62,49 +65,63 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
}, [initSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<MessagesEventBusProvider>
|
||||
<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>
|
||||
</MessagesEventBusProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
@@ -142,9 +159,7 @@ function App() {
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<KeyboardProvider>
|
||||
<InnerApp />
|
||||
</KeyboardProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
|
||||
+59
-32
@@ -4,8 +4,12 @@ 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 {MessagesEventBusProvider} from '#/state/messages/events'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
@@ -22,64 +26,87 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
}, [initSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (isInitialLoad) return null
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<MessagesEventBusProvider>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</MessagesEventBusProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-6
@@ -48,7 +48,7 @@ import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesListScreen} from './screens/Messages/List'
|
||||
import {MessagesScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
@@ -462,8 +462,8 @@ function MessagesTabNavigator() {
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
|
||||
options={{title: title(msg`Notifications`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
@@ -570,7 +570,7 @@ const LINKING = {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
if (name === 'Messages') {
|
||||
return buildStateObject('MessagesTab', 'MessagesList', params)
|
||||
return buildStateObject('MessagesTab', 'Messages', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
|
||||
@@ -16,12 +16,14 @@ export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
pendingDid,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
pendingDid: string | null
|
||||
}) {
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -31,6 +33,7 @@ export function AccountList({
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents={pendingDid ? 'none' : 'auto'}
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
@@ -43,6 +46,7 @@ export function AccountList({
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
@@ -50,7 +54,7 @@ export function AccountList({
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
onPress={pendingDid ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
@@ -59,8 +63,7 @@ export function AccountList({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -84,10 +87,12 @@ function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
isPendingAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
isPendingAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -115,7 +120,7 @@ function AccountItem({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
|
||||
@@ -64,7 +64,7 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
@@ -13,7 +13,6 @@ import {RichText} from '#/components/RichText'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
|
||||
type LabelingServiceProps = {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
@@ -69,9 +68,7 @@ export function LikeCount({count}: {count: number}) {
|
||||
t.atoms.text_contrast_medium,
|
||||
{fontWeight: '500'},
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {count} {pluralize(count, 'user')}
|
||||
</Trans>
|
||||
<Plural value={count} one="Liked by # user" other="Liked by # users" />
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
ItemProps,
|
||||
ItemTextProps,
|
||||
TriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as useMenuControl} from '#/components/Dialog'
|
||||
export {
|
||||
type DialogControlProps as MenuControlProps,
|
||||
useDialogControl as useMenuControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
export function useMemoControlContext() {
|
||||
return React.useContext(Context)
|
||||
|
||||
@@ -2,13 +2,12 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -371,7 +370,14 @@ function Inner({
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
})
|
||||
const pluralizedFollowings = plural(profile.followsCount || 0, {
|
||||
one: 'following',
|
||||
other: 'following',
|
||||
})
|
||||
const profileURL = makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
@@ -398,7 +404,9 @@ function Inner({
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following ? _('Following') : _('Follow')
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
@@ -407,7 +415,9 @@ function Inner({
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
@@ -434,22 +444,20 @@ function Inner({
|
||||
label={`${followers} ${pluralizedFollowers}`}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
|
||||
</Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowings}
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export function Outer({
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
{children}
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
@@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
|
||||
export function DescriptionText({
|
||||
children,
|
||||
selectable,
|
||||
}: React.PropsWithChildren<{selectable?: boolean}>) {
|
||||
const t = useTheme()
|
||||
const {descriptionId} = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
nativeID={descriptionId}
|
||||
selectable={selectable}
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner control={control} />
|
||||
<MutedWordsInner />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
function MutedWordsInner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
@@ -54,6 +54,7 @@ export function SwitchAccountDialog({
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const playHaptic = useHaptics()
|
||||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
const animationDidComplete = useSharedValue(false)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
|
||||
const shrink = useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
animationDidComplete.value = false
|
||||
})
|
||||
}, [animationDidComplete, scale])
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 750}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
|
||||
shrink()
|
||||
})
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, open])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '65%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menuControl = useMenuControl()
|
||||
const viewRef = React.useRef(null)
|
||||
|
||||
const [showActions, setShowActions] = React.useState(false)
|
||||
|
||||
const onMouseEnter = React.useCallback(() => {
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setShowActions(false)
|
||||
}, [])
|
||||
|
||||
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
|
||||
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
|
||||
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
|
||||
if (e.nativeEvent.relatedTarget == null) return
|
||||
setShowActions(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
|
||||
ref={viewRef}>
|
||||
{isFromSelf && (
|
||||
<View
|
||||
style={[
|
||||
a.mr_xl,
|
||||
a.justify_center,
|
||||
{
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: '65%',
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
{!isFromSelf && (
|
||||
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
|
||||
<MessageMenu
|
||||
message={message}
|
||||
control={menuControl}
|
||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
||||
onTriggerPress={onMouseEnter}
|
||||
// @ts-expect-error web only
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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}
|
||||
+74
-41
@@ -1,24 +1,27 @@
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {StyleProp, TextStyle, View} from 'react-native'
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageItem({
|
||||
export let MessageItem = ({
|
||||
item,
|
||||
next,
|
||||
pending,
|
||||
}: {
|
||||
item: ChatBskyConvoDefs.MessageView
|
||||
next:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
}) {
|
||||
pending?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -29,55 +32,73 @@ export function MessageItem({
|
||||
next.sender?.did === currentAccount?.did
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
|
||||
if (item.id.length > 13) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the next message is from a different sender, then it's the last in the group
|
||||
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
|
||||
return true
|
||||
}
|
||||
|
||||
// or, if there's a 10 minute gap between this message and the next
|
||||
// or, if there's a 3 minute gap between this message and the next
|
||||
if (ChatBskyConvoDefs.isMessageView(next)) {
|
||||
const thisDate = new Date(item.sentAt)
|
||||
const nextDate = new Date(next.sentAt)
|
||||
|
||||
const diff = nextDate.getTime() - thisDate.getTime()
|
||||
|
||||
// 10 minutes
|
||||
return diff > 10 * 60 * 1000
|
||||
// 3 minutes
|
||||
return diff > 3 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [item, next, isFromSelf, isNextFromSelf])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
lastInGroupRef.current = isLastInGroup
|
||||
}
|
||||
|
||||
const pendingColor =
|
||||
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.px_lg,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
{
|
||||
maxWidth: '65%',
|
||||
backgroundColor: isFromSelf
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]}>
|
||||
<Text
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
|
||||
<View
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? pending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
<Metadata
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
pending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
</ActionsWrapper>
|
||||
<MessageItemMetadata
|
||||
message={item}
|
||||
isLastInGroup={isLastInGroup}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
@@ -86,7 +107,9 @@ export function MessageItem({
|
||||
)
|
||||
}
|
||||
|
||||
function Metadata({
|
||||
MessageItem = React.memo(MessageItem)
|
||||
|
||||
let MessageItemMetadata = ({
|
||||
message,
|
||||
isLastInGroup,
|
||||
style,
|
||||
@@ -94,7 +117,7 @@ function Metadata({
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
isLastInGroup: boolean
|
||||
style: StyleProp<TextStyle>
|
||||
}) {
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -117,16 +140,15 @@ function Metadata({
|
||||
}
|
||||
|
||||
// if in the last day
|
||||
if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
|
||||
if (localDateString(now) === localDateString(date)) {
|
||||
return time
|
||||
}
|
||||
|
||||
// if yesterday
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (
|
||||
yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
|
||||
) {
|
||||
|
||||
if (localDateString(yesterday) === localDateString(date)) {
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
@@ -153,7 +175,7 @@ function Metadata({
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_xs,
|
||||
a.mt_xs,
|
||||
a.mt_2xs,
|
||||
a.mb_lg,
|
||||
style,
|
||||
]}>
|
||||
@@ -163,3 +185,14 @@ function Metadata({
|
||||
</TimeElapsed>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItemMetadata = React.memo(MessageItemMetadata)
|
||||
|
||||
function localDateString(date: Date) {
|
||||
// can't use toISOString because it should be in local time
|
||||
const mm = date.getMonth()
|
||||
const dd = date.getDate()
|
||||
const yyyy = date.getFullYear()
|
||||
// not padding with 0s because it's not necessary, it's just used for comparison
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useChat} from 'state/messages'
|
||||
import {ConvoStatus} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
||||
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
control,
|
||||
hideTrigger,
|
||||
triggerOpacity,
|
||||
}: {
|
||||
hideTrigger?: boolean
|
||||
triggerOpacity?: number
|
||||
onTriggerPress?: () => void
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
control: Menu.MenuControlProps
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const chat = useChat()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
// use when we have rich text
|
||||
// const str = richTextToString(richText, true)
|
||||
|
||||
Clipboard.setStringAsync(message.text)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [_, message.text])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
if (chat.status !== ConvoStatus.Ready) return
|
||||
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
chat
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, chat, message.id, retryDeleteControl])
|
||||
|
||||
const onReport = React.useCallback(() => {
|
||||
// TODO report the message
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{!hideTrigger && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[
|
||||
a.p_sm,
|
||||
a.rounded_full,
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<DotsHorizontal size="sm" style={t.atoms.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={_(msg`Delete message for me`)}
|
||||
onPress={deleteControl.open}>
|
||||
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
{!isFromSelf && (
|
||||
<Menu.Item
|
||||
testID="messageDropdownReportBtn"
|
||||
label={_(msg`Report message`)}
|
||||
onPress={onReport}>
|
||||
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={retryDeleteControl}
|
||||
title={_(msg`Failed to delete message`)}
|
||||
description={_(
|
||||
msg`An error occurred while trying to delete the message. Please try again.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageMenu = React.memo(MessageMenu)
|
||||
@@ -20,6 +20,7 @@ import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Button} from '../Button'
|
||||
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
|
||||
import {ListMaybePlaceholder} from '../Lists'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
@@ -178,7 +179,7 @@ function SearchablePeopleList({
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
<Dialog.Input
|
||||
label={_(msg`Search profiles`)}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
@@ -197,6 +198,7 @@ function SearchablePeopleList({
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
@@ -211,20 +213,35 @@ function SearchablePeopleList({
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{searchText.length > 0 && !actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
{searchText.length === 0 ? (
|
||||
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
|
||||
<Envelope width={64} fill={t.palette.contrast_200} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
a.mt_md,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Search for someone to start a conversation with.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
!actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useCallback, useRef} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
|
||||
const firstTimeRef = useRef(true)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (firstTimeRef.current) {
|
||||
firstTimeRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
refetch()
|
||||
}, [refetch]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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,6 +4,10 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
|
||||
})
|
||||
|
||||
export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z',
|
||||
})
|
||||
|
||||
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
@@ -39,7 +39,6 @@ export function LabelsOnMe({
|
||||
return null
|
||||
}
|
||||
|
||||
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
|
||||
return (
|
||||
<View style={[a.flex_row, style]}>
|
||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
||||
@@ -54,11 +53,18 @@ export function LabelsOnMe({
|
||||
}}>
|
||||
<ButtonIcon position="left" icon={CircleInfo} />
|
||||
<ButtonText style={[a.leading_snug]}>
|
||||
{labels.length}{' '}
|
||||
{labels.length === 1 ? (
|
||||
<Trans>label has been placed on this {labelTarget}</Trans>
|
||||
{isAccount ? (
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this account"
|
||||
other="# labels has been placed on this account"
|
||||
/>
|
||||
) : (
|
||||
<Trans>labels have been placed on this {labelTarget}</Trans>
|
||||
<Plural
|
||||
value={labels.length}
|
||||
one="# label has been placed on this content"
|
||||
other="# labels has been placed on this content"
|
||||
/>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
@@ -190,7 +190,7 @@ function AppealForm({
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
Toast.show(_(msg`Appeal submitted.`))
|
||||
Toast.show(_(msg`Appeal submitted`))
|
||||
} finally {
|
||||
control.close()
|
||||
}
|
||||
|
||||
@@ -121,20 +121,20 @@ function ModerationDetailsDialogInner({
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||
<Trans>
|
||||
This label was applied by{' '}
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>the author</Trans>
|
||||
) : (
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>This label was applied by the author.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This label was applied by{' '}
|
||||
<InlineLinkText
|
||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||
onPress={() => control.close()}
|
||||
style={a.text_md}>
|
||||
{desc.source}
|
||||
</InlineLinkText>
|
||||
)}
|
||||
.
|
||||
</Trans>
|
||||
.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
+12
-3
@@ -4,7 +4,16 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
|
||||
IS_DEV ? 'development' : UPDATES_CHANNEL
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER = process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? ''
|
||||
|
||||
// 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'
|
||||
})`
|
||||
|
||||
@@ -1,2 +1,17 @@
|
||||
import {version} from '../../package.json'
|
||||
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
|
||||
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
|
||||
// for Statsig reporting and shouldn't be used to identify a specific bundle.
|
||||
export const BUNDLE_DATE = IS_DEV
|
||||
? 0
|
||||
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
|
||||
|
||||
export const appVersion = version
|
||||
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Kind of a hack. We needed some way to distinguish these.
|
||||
const USER_ALT_PREFIX = 'Alt: '
|
||||
const DEFAULT_ALT_PREFIX = 'ALT: '
|
||||
|
||||
export function createGIFDescription(
|
||||
tenorDescription: string,
|
||||
preferredAlt: string = '',
|
||||
) {
|
||||
preferredAlt = preferredAlt.trim()
|
||||
if (preferredAlt !== '') {
|
||||
return USER_ALT_PREFIX + preferredAlt
|
||||
} else {
|
||||
return DEFAULT_ALT_PREFIX + tenorDescription
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAltFromGIFDescription(description: string): {
|
||||
isPreferred: boolean
|
||||
alt: string
|
||||
} {
|
||||
if (description.startsWith(USER_ALT_PREFIX)) {
|
||||
return {
|
||||
isPreferred: true,
|
||||
alt: description.replace(USER_ALT_PREFIX, ''),
|
||||
}
|
||||
} else if (description.startsWith(DEFAULT_ALT_PREFIX)) {
|
||||
return {
|
||||
isPreferred: false,
|
||||
alt: description.replace(DEFAULT_ALT_PREFIX, ''),
|
||||
}
|
||||
}
|
||||
return {
|
||||
isPreferred: false,
|
||||
alt: description,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -8,12 +8,14 @@ import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const {initSession} = useSessionApi()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
@@ -22,8 +24,12 @@ export function useAccountSwitcher() {
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
if (account.accessJwt) {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
@@ -33,10 +39,9 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await selectAccount(account, logContext)
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} else {
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
Toast.show(
|
||||
@@ -48,14 +53,12 @@ export function useAccountSwitcher() {
|
||||
logger.error(`switch account: selectAccount failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
clearCurrentAccount() // back user out to login
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Sorry! We need you to enter your password.`))
|
||||
}, 100)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
|
||||
[_, track, initSession, requestSwitchToAccount, pendingDid],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount}
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ export function useNavigationTabState() {
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
|
||||
}
|
||||
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtSearch &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
|
||||
export function useNavigationTabState() {
|
||||
@@ -9,6 +10,7 @@ export function useNavigationTabState() {
|
||||
isAtSearch: currentRoute === 'Search',
|
||||
isAtNotifications: currentRoute === 'Notifications',
|
||||
isAtMyProfile: currentRoute === 'MyProfile',
|
||||
isAtMessages: currentRoute === 'Messages',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
Image as RNImage,
|
||||
openCamera as openCameraFn,
|
||||
openCropper as openCropperFn,
|
||||
Image as RNImage,
|
||||
} from 'react-native-image-crop-picker'
|
||||
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
export {openPicker} from './picker.shared'
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
import {CameraOpts, CropperOptions} from './types'
|
||||
export {openPicker} from './picker.shared'
|
||||
import {unstable__openModal} from '#/state/modals'
|
||||
|
||||
@@ -16,6 +17,10 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
unstable__openModal({
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
dimensions:
|
||||
opts.height && opts.width
|
||||
? {width: opts.width, height: opts.height}
|
||||
: undefined,
|
||||
onSelect: (img?: RNImage) => {
|
||||
if (img) {
|
||||
resolve(img)
|
||||
|
||||
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
MessagesList: undefined
|
||||
Messages: undefined
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesList: undefined
|
||||
Messages: undefined
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesTab: undefined
|
||||
MessagesList: undefined
|
||||
Messages: undefined
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow_v2'
|
||||
| 'disable_min_shell_on_foregrounding_v2'
|
||||
| 'disable_min_shell_on_foregrounding_v3'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'dms'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'start_session_with_following_v2'
|
||||
| 'test_gate_1'
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
export function pluralize(n: number, base: string, plural?: string): string {
|
||||
if (n === 1) {
|
||||
return base
|
||||
}
|
||||
if (plural) {
|
||||
return plural
|
||||
}
|
||||
return base + 's'
|
||||
}
|
||||
|
||||
export function enforceLen(
|
||||
str: string,
|
||||
len: number,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import '@formatjs/intl-locale/polyfill'
|
||||
import '@formatjs/intl-pluralrules/polyfill'
|
||||
import '@formatjs/intl-pluralrules/locale-data/en'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
@@ -29,66 +33,82 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ca')
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/de')
|
||||
break
|
||||
}
|
||||
case AppLanguage.es: {
|
||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/es')
|
||||
break
|
||||
}
|
||||
case AppLanguage.fi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFi})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/fi')
|
||||
break
|
||||
}
|
||||
case AppLanguage.fr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/fr')
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ga')
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/hi')
|
||||
break
|
||||
}
|
||||
case AppLanguage.id: {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/id')
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/it')
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ja')
|
||||
break
|
||||
}
|
||||
case AppLanguage.ko: {
|
||||
i18n.loadAndActivate({locale, messages: messagesKo})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/ko')
|
||||
break
|
||||
}
|
||||
case AppLanguage.pt_BR: {
|
||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/pt')
|
||||
break
|
||||
}
|
||||
case AppLanguage.tr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/tr')
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/uk')
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_TW: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
||||
+882
-508
File diff suppressed because it is too large
Load Diff
+870
-492
File diff suppressed because it is too large
Load Diff
+866
-488
File diff suppressed because it is too large
Load Diff
+867
-493
File diff suppressed because it is too large
Load Diff
+917
-577
File diff suppressed because it is too large
Load Diff
+875
-497
File diff suppressed because it is too large
Load Diff
+875
-497
File diff suppressed because it is too large
Load Diff
+867
-493
File diff suppressed because it is too large
Load Diff
+1276
-1723
File diff suppressed because it is too large
Load Diff
+874
-499
File diff suppressed because it is too large
Load Diff
+867
-506
File diff suppressed because it is too large
Load Diff
+873
-508
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+868
-494
File diff suppressed because it is too large
Load Diff
+875
-497
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1282
-996
File diff suppressed because it is too large
Load Diff
@@ -9,4 +9,5 @@ export const DebugContext = {
|
||||
// e.g. composer: 'composer'
|
||||
session: 'session',
|
||||
notifications: 'notifications',
|
||||
convo: 'convo',
|
||||
} as const
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const router = new Router({
|
||||
CommunityGuidelines: '/support/community-guidelines',
|
||||
CopyrightPolicy: '/support/copyright',
|
||||
Hashtag: '/hashtag/:tag',
|
||||
MessagesList: '/messages',
|
||||
Messages: '/messages',
|
||||
MessagesSettings: '/messages/settings',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
|
||||
@@ -205,10 +204,16 @@ function msToString(ms: number | undefined): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
// hours
|
||||
return `${estimatedTimeHrs} ${pluralize(estimatedTimeHrs, 'hour')}`
|
||||
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
|
||||
one: 'hour',
|
||||
other: 'hours',
|
||||
})}`
|
||||
}
|
||||
// minutes
|
||||
return `${estimatedTimeMins} ${pluralize(estimatedTimeMins, 'minute')}`
|
||||
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
|
||||
one: 'minute',
|
||||
other: 'minutes',
|
||||
})}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ChooseAccountForm = ({
|
||||
onSelectAccount: (account?: SessionAccount) => void
|
||||
onPressBack: () => void
|
||||
}) => {
|
||||
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -34,33 +35,48 @@ export const ChooseAccountForm = ({
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
if (account.accessJwt) {
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
} else {
|
||||
try {
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
onSelectAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pendingDid) {
|
||||
// The session API isn't resilient to race conditions so let's just ignore this.
|
||||
return
|
||||
}
|
||||
if (!account.accessJwt) {
|
||||
// Move to login form.
|
||||
onSelectAccount(account)
|
||||
return
|
||||
}
|
||||
if (account.did === currentAccount?.did) {
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
// Move to login form.
|
||||
onSelectAccount(account)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
|
||||
[
|
||||
currentAccount,
|
||||
track,
|
||||
initSession,
|
||||
pendingDid,
|
||||
onSelectAccount,
|
||||
setShowLoggedOut,
|
||||
_,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -74,6 +90,7 @@ export const ChooseAccountForm = ({
|
||||
<AccountList
|
||||
onSelectAccount={onSelect}
|
||||
onSelectOther={() => onSelectAccount()}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_row]}>
|
||||
|
||||
@@ -12,20 +12,21 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
onFocus,
|
||||
onBlur,
|
||||
scrollToEnd,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
scrollToEnd: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const [message, setMessage] = React.useState('')
|
||||
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
|
||||
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
|
||||
@@ -39,11 +40,12 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
onSendMessage(message.trimEnd())
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}, [message, onSendMessage])
|
||||
}, [message, onSendMessage, playHaptic])
|
||||
|
||||
const onInputLayout = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
||||
@@ -55,49 +57,55 @@ export function MessageInput({
|
||||
|
||||
setMaxHeight(max)
|
||||
setIsInputScrollable(availableSpace < 30)
|
||||
|
||||
scrollToEnd()
|
||||
},
|
||||
[topInset],
|
||||
[scrollToEnd, topInset],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
a.mt_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextInput
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
multiline={true}
|
||||
onChangeText={setMessage}
|
||||
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
scrollEnabled={isInputScrollable}
|
||||
blurOnSubmit={false}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onContentSizeChange={onInputLayout}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
<View style={a.p_sm}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} />
|
||||
</Pressable>
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextInput
|
||||
accessibilityLabel={_(msg`Message input field`)}
|
||||
accessibilityHint={_(msg`Type your message here`)}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
multiline={true}
|
||||
onChangeText={setMessage}
|
||||
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
scrollEnabled={isInputScrollable}
|
||||
blurOnSubmit={false}
|
||||
onFocus={scrollToEnd}
|
||||
onContentSizeChange={onInputLayout}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export function MessageInput({
|
||||
onSendMessage,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
scrollToEnd: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -45,47 +44,51 @@ export function MessageInput({
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
a.mt_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<TextareaAutosize
|
||||
style={StyleSheet.flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
paddingTop: 6,
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
<View style={a.p_sm}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
a.pl_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 23},
|
||||
]}>
|
||||
<PaperPlane fill={t.palette.white} />
|
||||
</Pressable>
|
||||
<TextareaAutosize
|
||||
style={StyleSheet.flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
paddingTop: 4,
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={_(msg`Write a message`)}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Send message`)}
|
||||
accessibilityHint=""
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessageListError({
|
||||
item,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'error-recoverable'}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const message = React.useMemo(() => {
|
||||
return {
|
||||
[ConvoItemError.Network]: _(
|
||||
msg`There was an issue connecting to the chat.`,
|
||||
),
|
||||
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
|
||||
[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,13 +1,26 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react'
|
||||
import {FlatList, View, ViewToken} from 'react-native'
|
||||
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||
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 {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
|
||||
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -25,19 +38,40 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
)
|
||||
}
|
||||
|
||||
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} />
|
||||
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 (
|
||||
<View>
|
||||
<Button label="Retry" onPress={item.retry}>
|
||||
<ButtonText>Retry</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
return <RetryButton onPress={item.retry} />
|
||||
} else if (item.type === 'error-recoverable') {
|
||||
return <MessageListError item={item} />
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -47,105 +81,178 @@ function keyExtractor(item: ConvoItem) {
|
||||
return item.key
|
||||
}
|
||||
|
||||
function onScrollToEndFailed() {
|
||||
function onScrollToIndexFailed() {
|
||||
// Placeholder function. You have to give FlatList something or else it will error.
|
||||
}
|
||||
|
||||
export function MessagesList() {
|
||||
const chat = useChat()
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
// We use this to know if we should scroll after a new clop is added to the list
|
||||
const isAtBottom = useRef(false)
|
||||
|
||||
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
|
||||
return [
|
||||
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
|
||||
const firstVisibleIndex = info.viewableItems[0]?.index
|
||||
// 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)
|
||||
|
||||
isAtBottom.current = Number(firstVisibleIndex) < 2
|
||||
},
|
||||
{
|
||||
itemVisiblePercentThreshold: 50,
|
||||
minimumViewTime: 10,
|
||||
},
|
||||
]
|
||||
}, [])
|
||||
// This will be used on web to assist in determing if we need to maintain the content offset
|
||||
const isAtTop = useSharedValue(true)
|
||||
|
||||
const onContentSizeChange = useCallback(() => {
|
||||
if (isAtBottom.current) {
|
||||
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
||||
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
|
||||
// onStartReached to fire.
|
||||
const contentHeight = useSharedValue(0)
|
||||
|
||||
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
|
||||
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
|
||||
const isMomentumScrolling = useSharedValue(false)
|
||||
|
||||
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
|
||||
|
||||
// Every time the content size changes, that means one of two things is happening:
|
||||
// 1. New messages are being added from the log or from a message you have sent
|
||||
// 2. Old messages are being prepended to the top
|
||||
//
|
||||
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
|
||||
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
|
||||
//
|
||||
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
|
||||
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
|
||||
// we will not scroll whenever new items get prepended to the top.
|
||||
const onContentSizeChange = useCallback(
|
||||
(_: number, height: number) => {
|
||||
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
|
||||
// previous offset whenever we add new content to the previous offset whenever we add new content to the list.
|
||||
if (isWeb && isAtTop.value && hasInitiallyScrolled) {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: height - contentHeight.value,
|
||||
})
|
||||
}
|
||||
|
||||
contentHeight.value = height
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component
|
||||
if (height <= 50 || !isAtBottom.value) {
|
||||
return
|
||||
}
|
||||
|
||||
flatListRef.current?.scrollToOffset({
|
||||
animated: hasInitiallyScrolled,
|
||||
offset: height,
|
||||
})
|
||||
isMomentumScrolling.value = true
|
||||
},
|
||||
[
|
||||
contentHeight,
|
||||
hasInitiallyScrolled,
|
||||
isAtBottom.value,
|
||||
isAtTop.value,
|
||||
isMomentumScrolling,
|
||||
],
|
||||
)
|
||||
|
||||
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
|
||||
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
|
||||
const onStartReached = useCallback(() => {
|
||||
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
|
||||
chat.fetchMessageHistory()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
chat.service.fetchMessageHistory()
|
||||
}, [chat])
|
||||
|
||||
const onInputFocus = useCallback(() => {
|
||||
if (!isAtBottom.current) {
|
||||
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onInputBlur = useCallback(() => {}, [])
|
||||
}, [chat, hasInitiallyScrolled])
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
chat.service.sendMessage({
|
||||
text,
|
||||
})
|
||||
if (chat.status === ConvoStatus.Ready) {
|
||||
chat.sendMessage({
|
||||
text,
|
||||
})
|
||||
}
|
||||
},
|
||||
[chat.service],
|
||||
[chat],
|
||||
)
|
||||
|
||||
const onScroll = React.useCallback(
|
||||
(e: ReanimatedScrollEvent) => {
|
||||
'worklet'
|
||||
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
||||
|
||||
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
|
||||
// when a new message is added, hence the 100 pixel offset
|
||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
||||
isAtTop.value = e.contentOffset.y <= 1
|
||||
|
||||
// This number _must_ be the height of the MaybeLoader component.
|
||||
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
|
||||
// adds a 50 pixel offset.
|
||||
if (contentHeight.value > 50 && !hasInitiallyScrolled) {
|
||||
runOnJS(setHasInitiallyScrolled)(true)
|
||||
}
|
||||
},
|
||||
[contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop],
|
||||
)
|
||||
|
||||
const onMomentumEnd = React.useCallback(() => {
|
||||
'worklet'
|
||||
isMomentumScrolling.value = false
|
||||
}, [isMomentumScrolling])
|
||||
|
||||
const scrollToEnd = React.useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (isMomentumScrolling.value) return
|
||||
|
||||
flatListRef.current?.scrollToEnd({animated: true})
|
||||
isMomentumScrolling.value = true
|
||||
})
|
||||
}, [isMomentumScrolling])
|
||||
|
||||
const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
|
||||
|
||||
// This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly.
|
||||
const scrollToEndNow = React.useCallback(() => {
|
||||
flatListRef.current?.scrollToEnd({animated: false})
|
||||
}, [])
|
||||
|
||||
useKeyboardHandler({
|
||||
onMove: () => {
|
||||
'worklet'
|
||||
runOnJS(scrollToEndNow)()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
|
||||
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
|
||||
keyboardVerticalOffset={isIOS ? topInset : 0}
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={70}
|
||||
contentContainerStyle={{flex: 1}}>
|
||||
<FlatList
|
||||
data={
|
||||
chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
|
||||
}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{paddingHorizontal: 10}}
|
||||
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only
|
||||
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower
|
||||
// this...probably.
|
||||
initialNumToRender={20}
|
||||
// Same with the max to render per batch. Let's be safe for now though.
|
||||
maxToRenderPerBatch={25}
|
||||
inverted={true}
|
||||
onEndReached={onEndReached}
|
||||
onScrollToIndexFailed={onScrollToEndFailed}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 1,
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<MaybeLoader
|
||||
isLoading={
|
||||
chat.state.status === ConvoStatus.Ready &&
|
||||
chat.state.isFetchingHistory
|
||||
}
|
||||
/>
|
||||
}
|
||||
removeClippedSubviews={true}
|
||||
ref={flatListRef}
|
||||
keyboardDismissMode="none"
|
||||
/>
|
||||
|
||||
<View style={{paddingHorizontal: 10}}>
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
contentContainerStyle={a.flex_1}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={chat.items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableVirtualization={true}
|
||||
initialNumToRender={isWeb ? 50 : 25}
|
||||
maxToRenderPerBatch={isWeb ? 50 : 25}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 1,
|
||||
}}
|
||||
containWeb={true}
|
||||
contentContainerStyle={{paddingHorizontal: 10}}
|
||||
removeClippedSubviews={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<MaybeLoader isLoading={chat.isFetchingHistory} />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ScrollProvider>
|
||||
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
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, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
@@ -13,13 +14,12 @@ import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ChatProvider, useChat} from 'state/messages'
|
||||
import {ConvoStatus} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
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 {Button, ButtonIcon} from '#/components/Button'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ClipClopGate} from '../gate'
|
||||
@@ -43,29 +43,43 @@ export function MessagesConversationScreen({route}: Props) {
|
||||
|
||||
function Inner() {
|
||||
const chat = useChat()
|
||||
const {currentAccount} = useSession()
|
||||
const myDid = currentAccount?.did
|
||||
|
||||
const otherProfile = React.useMemo(() => {
|
||||
if (chat.state.status !== ConvoStatus.Ready) return
|
||||
return chat.state.convo.members.find(m => m.did !== myDid)
|
||||
}, [chat.state, myDid])
|
||||
if (
|
||||
chat.status === ConvoStatus.Uninitialized ||
|
||||
chat.status === ConvoStatus.Initializing
|
||||
) {
|
||||
return <ListMaybePlaceholder isLoading />
|
||||
}
|
||||
|
||||
// TODO whenever we have error messages, we should use them in here -hailey
|
||||
if (chat.state.status !== ConvoStatus.Ready || !otherProfile) {
|
||||
if (chat.status === ConvoStatus.Error) {
|
||||
// TODO
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={chat.state.status === ConvoStatus.Error}
|
||||
/>
|
||||
<View>
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Text>Something went wrong</Text>
|
||||
<Button
|
||||
label="Retry"
|
||||
onPress={() => {
|
||||
chat.error.retry()
|
||||
}}>
|
||||
<ButtonText>Retry</ButtonText>
|
||||
</Button>
|
||||
</CenteredView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Any other chat states (atm) are "ready" states
|
||||
*/
|
||||
|
||||
return (
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Header profile={otherProfile} />
|
||||
<MessagesList />
|
||||
</CenteredView>
|
||||
<KeyboardProvider>
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Header profile={chat.recipients[0]} />
|
||||
<MessagesList />
|
||||
</CenteredView>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,15 +92,20 @@ let Header = ({
|
||||
const {_} = useLingui()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const chat = useChat()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('MessagesList')
|
||||
navigation.replace('Messages')
|
||||
} else {
|
||||
navigation.pop()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
const onUpdateConvo = useCallback(() => {
|
||||
// TODO eric update muted state
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -95,22 +114,20 @@ let Header = ({
|
||||
a.border_b,
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_start,
|
||||
a.gap_lg,
|
||||
a.px_lg,
|
||||
a.py_sm,
|
||||
]}>
|
||||
{!gtTablet ? (
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
testID="conversationHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
}}
|
||||
style={{width: 30, height: 30}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint={_(msg`Access navigation links and settings`)}>
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
@@ -123,25 +140,23 @@ let Header = ({
|
||||
) : (
|
||||
<View style={{width: 30}} />
|
||||
)}
|
||||
<View style={[a.align_center, a.gap_sm]}>
|
||||
<UserAvatar size={32} avatar={profile.avatar} />
|
||||
<Text style={[a.text_lg, a.font_bold]}>
|
||||
<Trans>{profile.displayName}</Trans>
|
||||
<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>
|
||||
<View>
|
||||
<Button
|
||||
label={_(msg`Chat settings`)}
|
||||
color="secondary"
|
||||
size="large"
|
||||
variant="ghost"
|
||||
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
|
||||
onPress={() => {}}>
|
||||
<ButtonIcon icon={DotGrid_Stroke2_Corner0_Rounded} />
|
||||
</Button>
|
||||
</View>
|
||||
{chat.status === ConvoStatus.Ready ? (
|
||||
<ConvoMenu
|
||||
convo={chat.convo}
|
||||
profile={profile}
|
||||
onUpdateConvo={onUpdateConvo}
|
||||
currentScreen="conversation"
|
||||
/>
|
||||
) : (
|
||||
<View style={{width: 30}} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
Header = React.memo(Header)
|
||||
|
||||
@@ -6,12 +6,14 @@ 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'
|
||||
@@ -19,24 +21,40 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {NewChat} from '#/components/dms/NewChat'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
||||
import {Link} from '#/components/Link'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ClipClopGate} from '../gate'
|
||||
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
|
||||
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
|
||||
export function MessagesListScreen({navigation}: Props) {
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
||||
export function MessagesScreen({navigation}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const newChatControl = useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
// TEMP
|
||||
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
|
||||
const hasValidServiceUrl = useMemo(() => {
|
||||
const hash = sha256(serviceUrl)
|
||||
return (
|
||||
hash ===
|
||||
'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f'
|
||||
)
|
||||
}, [serviceUrl])
|
||||
|
||||
const renderButton = useCallback(() => {
|
||||
return (
|
||||
<Link
|
||||
@@ -59,7 +77,9 @@ export function MessagesListScreen({navigation}: Props) {
|
||||
fetchNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useListConvos()
|
||||
} = useListConvos({refetchInterval: 15_000})
|
||||
|
||||
useRefreshOnFocus(refetch)
|
||||
|
||||
const isError = !!error
|
||||
|
||||
@@ -109,6 +129,25 @@ export function MessagesListScreen({navigation}: Props) {
|
||||
const gate = useGate()
|
||||
if (!gate('dms')) return <ClipClopGate />
|
||||
|
||||
if (!hasValidServiceUrl) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={a.p_lg}>
|
||||
<View>
|
||||
<TextField.LabelText>Service URL</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
value={serviceUrl}
|
||||
onChangeText={text => setServiceUrl(text)}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
label="https://"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
if (conversations.length < 1) {
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
@@ -190,6 +229,7 @@ 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
|
||||
@@ -214,7 +254,10 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={`/messages/${convo.id}`} style={a.flex_1}>
|
||||
<Link
|
||||
to={`/messages/${convo.id}`}
|
||||
style={a.flex_1}
|
||||
onLongPress={isNative ? menuControl.open : undefined}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
@@ -230,7 +273,9 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
<PreviewableUserAvatar profile={otherUser} size={42} />
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
|
||||
<Text
|
||||
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
|
||||
{otherUser.displayName || otherUser.handle}
|
||||
@@ -267,12 +312,26 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
a.flex_0,
|
||||
a.ml_md,
|
||||
a.mt_sm,
|
||||
{backgroundColor: t.palette.primary_500},
|
||||
a.rounded_full,
|
||||
{height: 7, width: 7},
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
@@ -109,7 +109,7 @@ function PrimaryFeedCardInner({
|
||||
a.py_xs,
|
||||
ctx.selected && styles.textSelected,
|
||||
]}>
|
||||
by @{feed.creatorHandle}
|
||||
<Trans>by @{feed.creatorHandle}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -189,9 +189,9 @@ export function StepInterests() {
|
||||
color: t.palette.negative_900,
|
||||
},
|
||||
]}>
|
||||
Error:{' '}
|
||||
<Trans>Error:</Trans>{' '}
|
||||
</Text>
|
||||
{error?.message || 'an unknown error occurred'}
|
||||
{error?.message || _(msg`an unknown error occurred`)}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
@@ -21,7 +20,14 @@ export function ProfileHeaderMetrics({
|
||||
const {_} = useLingui()
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
})
|
||||
const pluralizedFollowings = plural(profile.followsCount || 0, {
|
||||
one: 'following',
|
||||
other: 'following',
|
||||
})
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -42,17 +48,15 @@ export function ProfileHeaderMetrics({
|
||||
style={[a.flex_row, t.atoms.text]}
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}>
|
||||
<Trans>
|
||||
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
following
|
||||
</Text>
|
||||
</Trans>
|
||||
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
{pluralizedFollowings}
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||
{formatCount(profile.postsCount || 0)}{' '}
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||
{pluralize(profile.postsCount || 0, 'post')}
|
||||
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
ModerationOpts,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
@@ -283,12 +282,10 @@ let ProfileHeaderLabeler = ({
|
||||
},
|
||||
}}
|
||||
size="tiny"
|
||||
label={_(
|
||||
msg`Liked by ${likeCount} ${pluralize(
|
||||
likeCount,
|
||||
'user',
|
||||
)}`,
|
||||
)}>
|
||||
label={plural(likeCount, {
|
||||
one: 'Liked by # user',
|
||||
other: 'Liked by # users',
|
||||
})}>
|
||||
{({hovered, focused, pressed}) => (
|
||||
<Text
|
||||
style={[
|
||||
@@ -298,9 +295,11 @@ let ProfileHeaderLabeler = ({
|
||||
(hovered || focused || pressed) &&
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {likeCount} {pluralize(likeCount, 'user')}
|
||||
</Trans>
|
||||
<Plural
|
||||
value={likeCount}
|
||||
one="Liked by # user"
|
||||
other="Liked by # users"
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -95,7 +95,11 @@ function ProfileEndOfFeed() {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
{paddingTop: 32, paddingBottom: 32, borderTopWidth: 1},
|
||||
]}>
|
||||
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
|
||||
<Trans>End of feed</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -151,11 +151,13 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
]}>
|
||||
<View style={[a.gap_sm, a.pb_3xl]}>
|
||||
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
|
||||
{state.serviceDescription &&
|
||||
!state.serviceDescription.phoneVerificationRequired
|
||||
? '2'
|
||||
: '3'}
|
||||
<Trans>
|
||||
Step {state.activeStep + 1} of{' '}
|
||||
{state.serviceDescription &&
|
||||
!state.serviceDescription.phoneVerificationRequired
|
||||
? '2'
|
||||
: '3'}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
{state.activeStep === SignupStep.INFO ? (
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
FeedDescriptor,
|
||||
FeedPostSliceItem,
|
||||
isFeedPostSlice,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {useAgent} from './session'
|
||||
|
||||
type StateContext = {
|
||||
enabled: boolean
|
||||
onItemSeen: (item: any) => void
|
||||
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}
|
||||
|
||||
const stateContext = React.createContext<StateContext>({
|
||||
enabled: false,
|
||||
onItemSeen: (_item: any) => {},
|
||||
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
|
||||
})
|
||||
|
||||
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
const {getAgent} = useAgent()
|
||||
const enabled = isDiscoverFeed(feed) && hasSession
|
||||
const queue = React.useRef<Set<string>>(new Set())
|
||||
const history = React.useRef<
|
||||
// Use a WeakSet so that we don't need to clear it.
|
||||
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
|
||||
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
|
||||
>(new WeakSet())
|
||||
|
||||
const sendToFeedNoDelay = React.useCallback(() => {
|
||||
const proxyAgent = getAgent().withProxy(
|
||||
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
||||
'bsky_fg',
|
||||
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
||||
'did:web:discover.bsky.app',
|
||||
) as BskyAgent
|
||||
|
||||
const interactions = Array.from(queue.current).map(toInteraction)
|
||||
queue.current.clear()
|
||||
|
||||
proxyAgent.app.bsky.feed
|
||||
.sendInteractions({interactions})
|
||||
.catch((e: any) => {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
})
|
||||
}, [getAgent])
|
||||
|
||||
const sendToFeed = React.useMemo(
|
||||
() =>
|
||||
throttle(sendToFeedNoDelay, 15e3, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
}),
|
||||
[sendToFeedNoDelay],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
if (state === 'background') {
|
||||
sendToFeed.flush()
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [enabled, sendToFeed])
|
||||
|
||||
const onItemSeen = React.useCallback(
|
||||
(slice: any) => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
if (!isFeedPostSlice(slice)) {
|
||||
return
|
||||
}
|
||||
for (const postItem of slice.items) {
|
||||
if (!history.current.has(postItem)) {
|
||||
history.current.add(postItem)
|
||||
queue.current.add(
|
||||
toString({
|
||||
item: postItem.uri,
|
||||
event: 'app.bsky.feed.defs#interactionSeen',
|
||||
feedContext: postItem.feedContext,
|
||||
}),
|
||||
)
|
||||
sendToFeed()
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, sendToFeed],
|
||||
)
|
||||
|
||||
const sendInteraction = React.useCallback(
|
||||
(interaction: AppBskyFeedDefs.Interaction) => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
if (!history.current.has(interaction)) {
|
||||
history.current.add(interaction)
|
||||
queue.current.add(toString(interaction))
|
||||
sendToFeed()
|
||||
}
|
||||
},
|
||||
[enabled, sendToFeed],
|
||||
)
|
||||
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
enabled,
|
||||
// pass this method to the <List> onItemSeen
|
||||
onItemSeen,
|
||||
// call on various events
|
||||
// queues the event to be sent with the throttled sendToFeed call
|
||||
sendInteraction,
|
||||
}
|
||||
}, [enabled, onItemSeen, sendInteraction])
|
||||
}
|
||||
|
||||
export const FeedFeedbackProvider = stateContext.Provider
|
||||
|
||||
export function useFeedFeedbackContext() {
|
||||
return React.useContext(stateContext)
|
||||
}
|
||||
|
||||
// TODO
|
||||
// We will introduce a permissions framework for 3p feeds to
|
||||
// take advantage of the feed feedback API. Until that's in
|
||||
// place, we're hardcoding it to the discover feed.
|
||||
// -prf
|
||||
function isDiscoverFeed(feed: FeedDescriptor) {
|
||||
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
|
||||
}
|
||||
|
||||
function toString(interaction: AppBskyFeedDefs.Interaction): string {
|
||||
return `${interaction.item}|${interaction.event}|${
|
||||
interaction.feedContext || ''
|
||||
}`
|
||||
}
|
||||
|
||||
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
|
||||
const [item, event, feedContext] = str.split('|')
|
||||
return {item, event, feedContext}
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import {describe, it} from '@jest/globals'
|
||||
|
||||
describe(`#/state/messages/convo`, () => {
|
||||
describe(`status states`, () => {
|
||||
describe(`init`, () => {
|
||||
it.todo(`fails if sender and recipients aren't found`)
|
||||
it.todo(`cannot re-initialize from a non-unintialized state`)
|
||||
it.todo(`can re-initialize from a failed state`)
|
||||
})
|
||||
|
||||
describe(`destroy`, () => {
|
||||
it.todo(`cannot be interacted with when destroyed`)
|
||||
it.todo(`polling is stopped when destroyed`)
|
||||
it.todo(`events are cleaned up when destroyed`)
|
||||
})
|
||||
describe(`resume`, () => {
|
||||
it.todo(`restores previous state if resume fails`)
|
||||
})
|
||||
|
||||
describe(`suspend`, () => {
|
||||
it.todo(`cannot be interacted with when suspended`)
|
||||
it.todo(`polling is stopped when suspended`)
|
||||
})
|
||||
|
||||
describe(`read states`, () => {
|
||||
it.todo(`should mark messages as read as they come in`)
|
||||
})
|
||||
|
||||
describe(`history fetching`, () => {
|
||||
|
||||
+821
-234
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
MessagesEventBusDispatch,
|
||||
MessagesEventBusDispatchEvent,
|
||||
MessagesEventBusError,
|
||||
MessagesEventBusErrorCode,
|
||||
MessagesEventBusParams,
|
||||
MessagesEventBusState,
|
||||
MessagesEventBusStatus,
|
||||
} from '#/state/messages/events/types'
|
||||
|
||||
const LOGGER_CONTEXT = 'MessagesEventBus'
|
||||
|
||||
const ACTIVE_POLL_INTERVAL = 60e3
|
||||
const BACKGROUND_POLL_INTERVAL = 60e3
|
||||
|
||||
export class MessagesEventBus {
|
||||
private id: string
|
||||
|
||||
private agent: BskyAgent
|
||||
private __tempFromUserDid: string
|
||||
private emitter = new EventEmitter()
|
||||
|
||||
private status: MessagesEventBusStatus = MessagesEventBusStatus.Uninitialized
|
||||
private pollInterval = ACTIVE_POLL_INTERVAL
|
||||
private error: MessagesEventBusError | undefined
|
||||
private latestRev: string | undefined = undefined
|
||||
|
||||
snapshot: MessagesEventBusState | undefined
|
||||
|
||||
constructor(params: MessagesEventBusParams) {
|
||||
this.id = nanoid(3)
|
||||
this.agent = params.agent
|
||||
this.__tempFromUserDid = params.__tempFromUserDid
|
||||
|
||||
this.subscribe = this.subscribe.bind(this)
|
||||
this.getSnapshot = this.getSnapshot.bind(this)
|
||||
this.init = this.init.bind(this)
|
||||
this.suspend = this.suspend.bind(this)
|
||||
this.resume = this.resume.bind(this)
|
||||
this.setPollInterval = this.setPollInterval.bind(this)
|
||||
this.trail = this.trail.bind(this)
|
||||
this.trailConvo = this.trailConvo.bind(this)
|
||||
}
|
||||
|
||||
private commit() {
|
||||
this.snapshot = undefined
|
||||
this.subscribers.forEach(subscriber => subscriber())
|
||||
}
|
||||
|
||||
private subscribers: (() => void)[] = []
|
||||
|
||||
subscribe(subscriber: () => void) {
|
||||
if (this.subscribers.length === 0) this.init()
|
||||
|
||||
this.subscribers.push(subscriber)
|
||||
|
||||
return () => {
|
||||
this.subscribers = this.subscribers.filter(s => s !== subscriber)
|
||||
if (this.subscribers.length === 0) this.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): MessagesEventBusState {
|
||||
if (!this.snapshot) this.snapshot = this.generateSnapshot()
|
||||
// logger.debug(`${LOGGER_CONTEXT}: snapshotted`, {}, logger.DebugContext.convo)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
private generateSnapshot(): MessagesEventBusState {
|
||||
switch (this.status) {
|
||||
case MessagesEventBusStatus.Initializing: {
|
||||
return {
|
||||
status: MessagesEventBusStatus.Initializing,
|
||||
rev: undefined,
|
||||
error: undefined,
|
||||
setPollInterval: this.setPollInterval,
|
||||
trail: this.trail,
|
||||
trailConvo: this.trailConvo,
|
||||
}
|
||||
}
|
||||
case MessagesEventBusStatus.Ready: {
|
||||
return {
|
||||
status: this.status,
|
||||
rev: this.latestRev!,
|
||||
error: undefined,
|
||||
setPollInterval: this.setPollInterval,
|
||||
trail: this.trail,
|
||||
trailConvo: this.trailConvo,
|
||||
}
|
||||
}
|
||||
case MessagesEventBusStatus.Suspended: {
|
||||
return {
|
||||
status: this.status,
|
||||
rev: this.latestRev,
|
||||
error: undefined,
|
||||
setPollInterval: this.setPollInterval,
|
||||
trail: this.trail,
|
||||
trailConvo: this.trailConvo,
|
||||
}
|
||||
}
|
||||
case MessagesEventBusStatus.Error: {
|
||||
return {
|
||||
status: MessagesEventBusStatus.Error,
|
||||
rev: this.latestRev,
|
||||
error: this.error || {
|
||||
code: MessagesEventBusErrorCode.Unknown,
|
||||
retry: () => {
|
||||
this.init()
|
||||
},
|
||||
},
|
||||
setPollInterval: this.setPollInterval,
|
||||
trail: this.trail,
|
||||
trailConvo: this.trailConvo,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: MessagesEventBusStatus.Uninitialized,
|
||||
rev: undefined,
|
||||
error: undefined,
|
||||
setPollInterval: this.setPollInterval,
|
||||
trail: this.trail,
|
||||
trailConvo: this.trailConvo,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(action: MessagesEventBusDispatch) {
|
||||
const prevStatus = this.status
|
||||
|
||||
switch (this.status) {
|
||||
case MessagesEventBusStatus.Uninitialized: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Init: {
|
||||
this.status = MessagesEventBusStatus.Initializing
|
||||
this.setup()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Initializing: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Ready: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.setPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Ready: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Backgrounded: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.setPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Suspended: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.setPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Error: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume:
|
||||
case MessagesEventBusDispatchEvent.Init: {
|
||||
this.status = MessagesEventBusStatus.Initializing
|
||||
this.error = undefined
|
||||
this.latestRev = undefined
|
||||
this.setup()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${LOGGER_CONTEXT}: dispatch '${action.event}'`,
|
||||
{
|
||||
id: this.id,
|
||||
prev: prevStatus,
|
||||
next: this.status,
|
||||
},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
|
||||
this.commit()
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: setup`, {}, logger.DebugContext.convo)
|
||||
|
||||
try {
|
||||
await this.initializeLatestRev()
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Ready})
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
context: `${LOGGER_CONTEXT}: setup failed`,
|
||||
})
|
||||
|
||||
this.dispatch({
|
||||
event: MessagesEventBusDispatchEvent.Error,
|
||||
payload: {
|
||||
exception: e,
|
||||
code: MessagesEventBusErrorCode.InitFailed,
|
||||
retry: () => {
|
||||
this.init()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: init`, {}, logger.DebugContext.convo)
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Init})
|
||||
}
|
||||
|
||||
background() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: background`, {}, logger.DebugContext.convo)
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Background})
|
||||
}
|
||||
|
||||
suspend() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: suspend`, {}, logger.DebugContext.convo)
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Suspend})
|
||||
}
|
||||
|
||||
resume() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: resume`, {}, logger.DebugContext.convo)
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Resume})
|
||||
}
|
||||
|
||||
setPollInterval(interval: number) {
|
||||
this.pollInterval = interval
|
||||
this.resetPoll()
|
||||
}
|
||||
|
||||
trail(handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void) {
|
||||
this.emitter.on('events', handler)
|
||||
return () => {
|
||||
this.emitter.off('events', handler)
|
||||
}
|
||||
}
|
||||
|
||||
trailConvo(
|
||||
convoId: string,
|
||||
handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void,
|
||||
) {
|
||||
const handle = (events: ChatBskyConvoGetLog.OutputSchema['logs']) => {
|
||||
const convoEvents = events.filter(ev => {
|
||||
if (typeof ev.convoId === 'string' && ev.convoId === convoId) {
|
||||
return ev.convoId === convoId
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (convoEvents.length > 0) {
|
||||
handler(convoEvents)
|
||||
}
|
||||
}
|
||||
|
||||
this.emitter.on('events', handle)
|
||||
return () => {
|
||||
this.emitter.off('events', handle)
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeLatestRev() {
|
||||
logger.debug(
|
||||
`${LOGGER_CONTEXT}: initialize latest rev`,
|
||||
{},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
|
||||
const response = await this.agent.api.chat.bsky.convo.listConvos(
|
||||
{
|
||||
limit: 1,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const {convos} = response.data
|
||||
|
||||
for (const convo of convos) {
|
||||
if (convo.rev > (this.latestRev = this.latestRev || convo.rev)) {
|
||||
this.latestRev = convo.rev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Polling
|
||||
*/
|
||||
|
||||
private isPolling = false
|
||||
private pollIntervalRef: NodeJS.Timeout | undefined
|
||||
|
||||
private resetPoll() {
|
||||
this.stopPoll()
|
||||
this.startPoll()
|
||||
}
|
||||
|
||||
private startPoll() {
|
||||
if (!this.isPolling) this.poll()
|
||||
|
||||
this.pollIntervalRef = setInterval(() => {
|
||||
if (this.isPolling) return
|
||||
this.poll()
|
||||
}, this.pollInterval)
|
||||
}
|
||||
|
||||
private stopPoll() {
|
||||
if (this.pollIntervalRef) clearInterval(this.pollIntervalRef)
|
||||
}
|
||||
|
||||
private async poll() {
|
||||
if (this.isPolling) return
|
||||
|
||||
this.isPolling = true
|
||||
|
||||
logger.debug(`${LOGGER_CONTEXT}: poll`, {}, logger.DebugContext.convo)
|
||||
|
||||
try {
|
||||
const response = await this.agent.api.chat.bsky.convo.getLog(
|
||||
{
|
||||
cursor: this.latestRev,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const {logs: events} = response.data
|
||||
|
||||
let needsEmit = false
|
||||
let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = []
|
||||
|
||||
for (const ev of events) {
|
||||
/*
|
||||
* If there's a rev, we should handle it. If there's not a rev, we don't
|
||||
* know what it is.
|
||||
*/
|
||||
if (typeof ev.rev === 'string') {
|
||||
/*
|
||||
* We only care about new events
|
||||
*/
|
||||
if (ev.rev > (this.latestRev = this.latestRev || ev.rev)) {
|
||||
/*
|
||||
* Update rev regardless of if it's a ev type we care about or not
|
||||
*/
|
||||
this.latestRev = ev.rev
|
||||
needsEmit = true
|
||||
batch.push(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsEmit) {
|
||||
try {
|
||||
this.emitter.emit('events', batch)
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
context: `${LOGGER_CONTEXT}: process latest events`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {context: `${LOGGER_CONTEXT}: poll events failed`})
|
||||
|
||||
this.dispatch({
|
||||
event: MessagesEventBusDispatchEvent.Error,
|
||||
payload: {
|
||||
exception: e,
|
||||
code: MessagesEventBusErrorCode.PollFailed,
|
||||
retry: () => {
|
||||
this.init()
|
||||
},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
this.isPolling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {MessagesEventBusState} from '#/state/messages/events/types'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
const MessagesEventBusContext =
|
||||
React.createContext<MessagesEventBusState | null>(null)
|
||||
|
||||
export function useMessagesEventBus() {
|
||||
const ctx = React.useContext(MessagesEventBusContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useChat must be used within a ChatProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessagesEventBusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const {getAgent} = useAgent()
|
||||
const [bus] = React.useState(
|
||||
() =>
|
||||
new MessagesEventBus({
|
||||
agent: new BskyAgent({
|
||||
service: serviceUrl,
|
||||
}),
|
||||
__tempFromUserDid: getAgent().session?.did!,
|
||||
}),
|
||||
)
|
||||
const service = React.useSyncExternalStore(bus.subscribe, bus.getSnapshot)
|
||||
|
||||
if (isWeb && IS_DEV) {
|
||||
// @ts-ignore
|
||||
window.messagesEventBus = service
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
bus.resume()
|
||||
} else {
|
||||
bus.background()
|
||||
}
|
||||
}
|
||||
|
||||
const sub = AppState.addEventListener('change', handleAppStateChange)
|
||||
|
||||
return () => {
|
||||
sub.remove()
|
||||
}
|
||||
}, [bus])
|
||||
|
||||
return (
|
||||
<MessagesEventBusContext.Provider value={service}>
|
||||
{children}
|
||||
</MessagesEventBusContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
|
||||
|
||||
export type MessagesEventBusParams = {
|
||||
agent: BskyAgent
|
||||
__tempFromUserDid: string
|
||||
}
|
||||
|
||||
export enum MessagesEventBusStatus {
|
||||
Uninitialized = 'uninitialized',
|
||||
Initializing = 'initializing',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Backgrounded = 'backgrounded',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum MessagesEventBusDispatchEvent {
|
||||
Init = 'init',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Background = 'background',
|
||||
Suspend = 'suspend',
|
||||
Resume = 'resume',
|
||||
}
|
||||
|
||||
export enum MessagesEventBusErrorCode {
|
||||
Unknown = 'unknown',
|
||||
InitFailed = 'initFailed',
|
||||
PollFailed = 'pollFailed',
|
||||
}
|
||||
|
||||
export type MessagesEventBusError = {
|
||||
code: MessagesEventBusErrorCode
|
||||
exception?: Error
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export type MessagesEventBusDispatch =
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Init
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Ready
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Background
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Suspend
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Resume
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Error
|
||||
payload: MessagesEventBusError
|
||||
}
|
||||
|
||||
export type TrailHandler = (
|
||||
events: ChatBskyConvoGetLog.OutputSchema['logs'],
|
||||
) => void
|
||||
|
||||
export type MessagesEventBusState =
|
||||
| {
|
||||
status: MessagesEventBusStatus.Uninitialized
|
||||
rev: undefined
|
||||
error: undefined
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
| {
|
||||
status: MessagesEventBusStatus.Initializing
|
||||
rev: undefined
|
||||
error: undefined
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
| {
|
||||
status: MessagesEventBusStatus.Ready
|
||||
rev: string
|
||||
error: undefined
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
| {
|
||||
status: MessagesEventBusStatus.Backgrounded
|
||||
rev: string | undefined
|
||||
error: undefined
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
| {
|
||||
status: MessagesEventBusStatus.Suspended
|
||||
rev: string | undefined
|
||||
error: undefined
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
| {
|
||||
status: MessagesEventBusStatus.Error
|
||||
rev: string | undefined
|
||||
error: MessagesEventBusError
|
||||
setPollInterval: (interval: number) => void
|
||||
trail: (handler: TrailHandler) => () => void
|
||||
trailConvo: (convoId: string, handler: TrailHandler) => () => void
|
||||
}
|
||||
@@ -1,31 +1,30 @@
|
||||
import React from 'react'
|
||||
import React, {useContext, useState, useSyncExternalStore} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||
|
||||
import {Convo, ConvoParams} from '#/state/messages/convo'
|
||||
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
|
||||
const ChatContext = React.createContext<{
|
||||
service: Convo
|
||||
state: Convo['state']
|
||||
}>({
|
||||
// @ts-ignore
|
||||
service: null,
|
||||
// @ts-ignore
|
||||
state: null,
|
||||
})
|
||||
const ChatContext = React.createContext<ConvoState | null>(null)
|
||||
|
||||
export function useChat() {
|
||||
return React.useContext(ChatContext)
|
||||
const ctx = useContext(ChatContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useChat must be used within a ChatProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
convoId,
|
||||
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
|
||||
const isScreenFocused = useIsFocused()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const {getAgent} = useAgent()
|
||||
const [service] = React.useState(
|
||||
const [convo] = useState(
|
||||
() =>
|
||||
new Convo({
|
||||
convoId,
|
||||
@@ -35,23 +34,35 @@ export function ChatProvider({
|
||||
__tempFromUserDid: getAgent().session?.did!,
|
||||
}),
|
||||
)
|
||||
const [state, setState] = React.useState(service.state)
|
||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||
|
||||
React.useEffect(() => {
|
||||
service.initialize()
|
||||
}, [service])
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
convo.resume()
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => setState(service.state)
|
||||
service.on('update', update)
|
||||
return () => {
|
||||
service.destroy()
|
||||
}
|
||||
}, [service])
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{state, service}}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
return () => {
|
||||
convo.background()
|
||||
}
|
||||
}, [convo]),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: string) => {
|
||||
if (isScreenFocused) {
|
||||
if (nextAppState === 'active') {
|
||||
convo.resume()
|
||||
} else {
|
||||
convo.background()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sub = AppState.addEventListener('change', handleAppStateChange)
|
||||
|
||||
return () => {
|
||||
sub.remove()
|
||||
}
|
||||
}, [convo, isScreenFocused])
|
||||
|
||||
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface EditImageModal {
|
||||
export interface CropImageModal {
|
||||
name: 'crop-image'
|
||||
uri: string
|
||||
dimensions?: {width: number; height: number}
|
||||
onSelect: (img?: RNImage) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ export function useActorAutocompleteQuery(
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {getAgent} = useAgent()
|
||||
|
||||
prefix = prefix.toLowerCase()
|
||||
prefix = prefix.toLowerCase().trim()
|
||||
if (prefix.endsWith('.')) {
|
||||
// Going from "foo" to "foo." should not clear matches.
|
||||
prefix = prefix.slice(0, -1)
|
||||
}
|
||||
|
||||
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {RQKEY as CONVO_KEY} from './conversation'
|
||||
import {useHeaders} from './temp-headers'
|
||||
@@ -30,6 +31,9 @@ export function useGetConvoForMembers({
|
||||
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError,
|
||||
onError: error => {
|
||||
logger.error(error)
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BskyAgent,
|
||||
ChatBskyConvoLeaveConvo,
|
||||
ChatBskyConvoListConvos,
|
||||
} from '@atproto-labs/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
|
||||
import {useHeaders} from './temp-headers'
|
||||
|
||||
export function useLeaveConvo(
|
||||
convoId: string,
|
||||
{
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
|
||||
onError?: (error: Error) => void
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
const {data} = await agent.api.chat.bsky.convo.leaveConvo(
|
||||
{convoId},
|
||||
{headers, encoding: 'application/json'},
|
||||
)
|
||||
|
||||
return data
|
||||
},
|
||||
onMutate: () => {
|
||||
queryClient.setQueryData(
|
||||
CONVO_LIST_KEY,
|
||||
(old?: {
|
||||
pageParams: Array<string | undefined>
|
||||
pages: Array<ChatBskyConvoListConvos.OutputSchema>
|
||||
}) => {
|
||||
console.log('old', old)
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map(page => {
|
||||
return {
|
||||
...page,
|
||||
convos: page.convos.filter(convo => convo.id !== convoId),
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onSuccess: data => {
|
||||
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: error => {
|
||||
logger.error(error)
|
||||
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {useHeaders} from './temp-headers'
|
||||
export const RQKEY = ['convo-list']
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useListConvos() {
|
||||
export function useListConvos({refetchInterval}: {refetchInterval: number}) {
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
|
||||
@@ -24,5 +24,6 @@ export function useListConvos() {
|
||||
},
|
||||
initialPageParam: undefined as RQPageParam,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BskyAgent,
|
||||
ChatBskyConvoMuteConvo,
|
||||
ChatBskyConvoUnmuteConvo,
|
||||
} from '@atproto-labs/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {RQKEY as CONVO_KEY} from './conversation'
|
||||
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
|
||||
import {useHeaders} from './temp-headers'
|
||||
|
||||
export function useMuteConvo(
|
||||
convoId: string,
|
||||
{
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void
|
||||
onError?: (error: Error) => void
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
const {data} = await agent.api.chat.bsky.convo.muteConvo(
|
||||
{convoId},
|
||||
{headers, encoding: 'application/json'},
|
||||
)
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: data => {
|
||||
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
|
||||
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: error => {
|
||||
logger.error(error)
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnmuteConvo(
|
||||
convoId: string,
|
||||
{
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void
|
||||
onError?: (error: Error) => void
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
|
||||
{convoId},
|
||||
{headers, encoding: 'application/json'},
|
||||
)
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: data => {
|
||||
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
|
||||
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: error => {
|
||||
logger.error(error)
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -70,10 +70,12 @@ export interface FeedPostSliceItem {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
||||
feedContext: string | undefined
|
||||
moderation: ModerationDecision
|
||||
}
|
||||
|
||||
export interface FeedPostSlice {
|
||||
_isFeedPostSlice: boolean
|
||||
_reactKey: string
|
||||
rootUri: string
|
||||
isThread: boolean
|
||||
@@ -276,6 +278,7 @@ export function usePostFeedQuery(
|
||||
|
||||
return {
|
||||
_reactKey: slice._reactKey,
|
||||
_isFeedPostSlice: true,
|
||||
rootUri: slice.rootItem.post.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
@@ -300,6 +303,7 @@ export function usePostFeedQuery(
|
||||
i === 0 && slice.source
|
||||
? slice.source
|
||||
: item.reason,
|
||||
feedContext: item.feedContext,
|
||||
moderation: moderations[i],
|
||||
}
|
||||
}
|
||||
@@ -507,3 +511,9 @@ export function resetProfilePostsQueries(
|
||||
})
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
export function isFeedPostSlice(v: any): v is FeedPostSlice {
|
||||
return (
|
||||
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
|
||||
)
|
||||
}
|
||||
|
||||
+209
-227
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
|
||||
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
@@ -35,8 +35,6 @@ const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
configureModerationForGuest()
|
||||
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
isInitialLoad: true,
|
||||
isSwitchingAccounts: false,
|
||||
accounts: [],
|
||||
currentAccount: undefined,
|
||||
hasSession: false,
|
||||
@@ -47,9 +45,7 @@ const ApiContext = React.createContext<SessionApiContext>({
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
initSession: async () => {},
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
selectAccount: async () => {},
|
||||
updateCurrentAccount: () => {},
|
||||
clearCurrentAccount: () => {},
|
||||
})
|
||||
@@ -60,33 +56,26 @@ function __getAgent() {
|
||||
return __globalAgent
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: BskyAgent
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
type State = {
|
||||
accounts: SessionStateContext['accounts']
|
||||
currentAccountDid: string | undefined
|
||||
currentAgentState: AgentState
|
||||
needsPersist: boolean
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
||||
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
|
||||
const [state, setState] = React.useState<State>({
|
||||
const [state, setState] = React.useState<State>(() => ({
|
||||
accounts: persisted.get('session').accounts,
|
||||
currentAccountDid: undefined, // assume logged out to start
|
||||
needsPersist: false,
|
||||
})
|
||||
|
||||
const upsertAccount = React.useCallback(
|
||||
(account: SessionAccount, expired = false) => {
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAccountDid: expired ? undefined : account.did,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined, // assume logged out to start
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
needsPersist: false,
|
||||
}))
|
||||
|
||||
const clearCurrentAccount = React.useCallback(() => {
|
||||
logger.warn(`session: clear current account`)
|
||||
@@ -94,80 +83,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAccountDid: undefined,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
}, [setState])
|
||||
|
||||
const createPersistSessionHandler = React.useCallback(
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(
|
||||
agent: BskyAgent,
|
||||
account: SessionAccount,
|
||||
persistSessionCallback: (props: {
|
||||
expired: boolean
|
||||
refreshedAccount: SessionAccount
|
||||
}) => void,
|
||||
{
|
||||
networkErrorCallback,
|
||||
}: {
|
||||
networkErrorCallback?: () => void
|
||||
} = {},
|
||||
): AtpPersistSessionHandler => {
|
||||
return function persistSession(event, session) {
|
||||
const expired = event === 'expired' || event === 'create-failed'
|
||||
event: AtpSessionEvent,
|
||||
session: AtpSessionData | undefined,
|
||||
) => {
|
||||
const expired = event === 'expired' || event === 'create-failed'
|
||||
|
||||
if (event === 'network-error') {
|
||||
logger.warn(
|
||||
`session: persistSessionHandler received network-error event`,
|
||||
)
|
||||
networkErrorCallback?.()
|
||||
return
|
||||
}
|
||||
if (event === 'network-error') {
|
||||
logger.warn(
|
||||
`session: persistSessionHandler received network-error event`,
|
||||
)
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: use agentToSessionAccount for this too.
|
||||
const refreshedAccount: SessionAccount = {
|
||||
service: account.service,
|
||||
did: session?.did || account.did,
|
||||
handle: session?.handle || account.handle,
|
||||
email: session?.email || account.email,
|
||||
emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
|
||||
emailAuthFactor: session?.emailAuthFactor || account.emailAuthFactor,
|
||||
deactivated: isSessionDeactivated(session?.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
|
||||
/*
|
||||
* Tokens are undefined if the session expires, or if creation fails for
|
||||
* any reason e.g. tokens are invalid, network error, etc.
|
||||
*/
|
||||
refreshJwt: session?.refreshJwt,
|
||||
accessJwt: session?.accessJwt,
|
||||
}
|
||||
|
||||
logger.debug(`session: persistSession`, {
|
||||
event,
|
||||
deactivated: refreshedAccount.deactivated,
|
||||
})
|
||||
|
||||
if (expired) {
|
||||
logger.warn(`session: expired`)
|
||||
emitSessionDropped()
|
||||
}
|
||||
// TODO: use agentToSessionAccount for this too.
|
||||
const refreshedAccount: SessionAccount = {
|
||||
service: account.service,
|
||||
did: session?.did ?? account.did,
|
||||
handle: session?.handle ?? account.handle,
|
||||
email: session?.email ?? account.email,
|
||||
emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed,
|
||||
emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor,
|
||||
deactivated: isSessionDeactivated(session?.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
|
||||
/*
|
||||
* If the session expired, or it was successfully created/updated, we want
|
||||
* to update/persist the data.
|
||||
*
|
||||
* If the session creation failed, it could be a network error, or it could
|
||||
* be more serious like an invalid token(s). We can't differentiate, so in
|
||||
* order to allow the user to get a fresh token (if they need it), we need
|
||||
* to persist this data and wipe their tokens, effectively logging them
|
||||
* out.
|
||||
* Tokens are undefined if the session expires, or if creation fails for
|
||||
* any reason e.g. tokens are invalid, network error, etc.
|
||||
*/
|
||||
persistSessionCallback({
|
||||
expired,
|
||||
refreshedAccount,
|
||||
})
|
||||
refreshJwt: session?.refreshJwt,
|
||||
accessJwt: session?.accessJwt,
|
||||
}
|
||||
|
||||
logger.debug(`session: persistSession`, {
|
||||
event,
|
||||
deactivated: refreshedAccount.deactivated,
|
||||
})
|
||||
|
||||
if (expired) {
|
||||
logger.warn(`session: expired`)
|
||||
emitSessionDropped()
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/*
|
||||
* If the session expired, or it was successfully created/updated, we want
|
||||
* to update/persist the data.
|
||||
*
|
||||
* If the session creation failed, it could be a network error, or it could
|
||||
* be more serious like an invalid token(s). We can't differentiate, so in
|
||||
* order to allow the user to get a fresh token (if they need it), we need
|
||||
* to persist this data and wipe their tokens, effectively logging them
|
||||
* out.
|
||||
*/
|
||||
setState(s => {
|
||||
const existingAccount = s.accounts.find(
|
||||
a => a.did === refreshedAccount.did,
|
||||
)
|
||||
if (
|
||||
!expired &&
|
||||
existingAccount &&
|
||||
refreshedAccount &&
|
||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||
) {
|
||||
// Fast path without a state update.
|
||||
return s
|
||||
}
|
||||
return {
|
||||
accounts: [
|
||||
refreshedAccount,
|
||||
...s.accounts.filter(a => a.did !== refreshedAccount.did),
|
||||
],
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
@@ -197,26 +218,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
)
|
||||
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
upsertAccount(account)
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`session: created account`, {}, logger.DebugContext.session)
|
||||
track('Create Account')
|
||||
logEvent('account:create:success', {})
|
||||
},
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
[onAgentSessionChange],
|
||||
)
|
||||
|
||||
const login = React.useCallback<SessionApiContext['login']>(
|
||||
@@ -229,35 +252,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
authFactorToken,
|
||||
})
|
||||
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
|
||||
__globalAgent = agent
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
await fetchingGates
|
||||
upsertAccount(account)
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
|
||||
|
||||
track('Sign In', {resumedSession: false})
|
||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||
},
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
[onAgentSessionChange],
|
||||
)
|
||||
|
||||
const logout = React.useCallback<SessionApiContext['logout']>(
|
||||
async logContext => {
|
||||
logger.debug(`session: logout`)
|
||||
clearCurrentAccount()
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.map(a => ({
|
||||
@@ -265,13 +292,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
})),
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
logEvent('account:loggedOut', {logContext})
|
||||
},
|
||||
[clearCurrentAccount, setState],
|
||||
[setState],
|
||||
)
|
||||
|
||||
const initSession = React.useCallback<SessionApiContext['initSession']>(
|
||||
@@ -286,16 +316,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
|
||||
}
|
||||
|
||||
agent.setPersistSessionHandler(
|
||||
createPersistSessionHandler(
|
||||
agent,
|
||||
account,
|
||||
({expired, refreshedAccount}) => {
|
||||
upsertAccount(refreshedAccount, expired)
|
||||
},
|
||||
{networkErrorCallback: clearCurrentAccount},
|
||||
),
|
||||
)
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
@@ -305,8 +328,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
isSessionDeactivated(account.accessJwt) || account.deactivated
|
||||
|
||||
const prevSession = {
|
||||
accessJwt: account.accessJwt || '',
|
||||
refreshJwt: account.refreshJwt || '',
|
||||
accessJwt: account.accessJwt ?? '',
|
||||
refreshJwt: account.refreshJwt ?? '',
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
}
|
||||
@@ -314,23 +337,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (isSessionExpired(account)) {
|
||||
logger.debug(`session: attempting to resume using previous session`)
|
||||
|
||||
try {
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
upsertAccount(freshAccount)
|
||||
} catch (e) {
|
||||
/*
|
||||
* Note: `agent.persistSession` is also called when this fails, and
|
||||
* we handle that failure via `createPersistSessionHandler`
|
||||
*/
|
||||
logger.info(`session: resumeSessionWithFreshAccount failed`, {
|
||||
message: e,
|
||||
})
|
||||
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
// TODO: Should this update currentAccountDid?
|
||||
}
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
freshAccount,
|
||||
...s.accounts.filter(a => a.did !== freshAccount.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: freshAccount.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.debug(`session: attempting to reuse previous session`)
|
||||
|
||||
@@ -338,7 +360,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
upsertAccount(account)
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
account,
|
||||
...s.accounts.filter(a => a.did !== account.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (accountOrSessionDeactivated) {
|
||||
// don't attempt to resume
|
||||
@@ -349,26 +383,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
resumeSessionWithFreshAccount()
|
||||
.then(freshAccount => {
|
||||
if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
|
||||
logger.info(
|
||||
`session: reuse of previous session returned a fresh account, upserting`,
|
||||
)
|
||||
upsertAccount(freshAccount)
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
/*
|
||||
* Note: `agent.persistSession` is also called when this fails, and
|
||||
* we handle that failure via `createPersistSessionHandler`
|
||||
*/
|
||||
logger.info(`session: resumeSessionWithFreshAccount failed`, {
|
||||
message: e,
|
||||
})
|
||||
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
// TODO: Should this update currentAccountDid?
|
||||
})
|
||||
}
|
||||
|
||||
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
||||
@@ -386,22 +400,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return sessionAccount
|
||||
}
|
||||
},
|
||||
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async account => {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
},
|
||||
[initSession],
|
||||
[onAgentSessionChange],
|
||||
)
|
||||
|
||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||
@@ -409,7 +408,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.filter(a => a.did !== account.did),
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
@@ -423,23 +422,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account => {
|
||||
setState(s => {
|
||||
const currentAccount = s.accounts.find(
|
||||
a => a.did === s.currentAccountDid,
|
||||
a => a.did === s.currentAgentState.did,
|
||||
)
|
||||
// ignore, should never happen
|
||||
if (!currentAccount) return s
|
||||
|
||||
const updatedAccount = {
|
||||
...currentAccount,
|
||||
handle: account.handle || currentAccount.handle,
|
||||
email: account.email || currentAccount.email,
|
||||
handle: account.handle ?? currentAccount.handle,
|
||||
email: account.email ?? currentAccount.email,
|
||||
emailConfirmed:
|
||||
account.emailConfirmed !== undefined
|
||||
? account.emailConfirmed
|
||||
: currentAccount.emailConfirmed,
|
||||
account.emailConfirmed ?? currentAccount.emailConfirmed,
|
||||
emailAuthFactor:
|
||||
account.emailAuthFactor !== undefined
|
||||
? account.emailAuthFactor
|
||||
: currentAccount.emailAuthFactor,
|
||||
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -447,7 +442,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
updatedAccount,
|
||||
...s.accounts.filter(a => a.did !== currentAccount.did),
|
||||
],
|
||||
currentAccountDid: s.currentAccountDid,
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
@@ -455,30 +450,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[setState],
|
||||
)
|
||||
|
||||
const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
|
||||
async (account, logContext) => {
|
||||
setIsSwitchingAccounts(true)
|
||||
try {
|
||||
await initSession(account)
|
||||
setIsSwitchingAccounts(false)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
} catch (e) {
|
||||
// reset this in case of error
|
||||
setIsSwitchingAccounts(false)
|
||||
// but other listeners need a throw
|
||||
throw e
|
||||
}
|
||||
},
|
||||
[initSession],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.needsPersist) {
|
||||
state.needsPersist = false
|
||||
persisted.write('session', {
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAccountDid,
|
||||
a => a.did === state.currentAgentState.did,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -489,16 +467,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const persistedSession = persisted.get('session')
|
||||
|
||||
logger.debug(`session: persisted onUpdate`, {})
|
||||
setState(s => ({
|
||||
accounts: persistedSession.accounts,
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
|
||||
const selectedAccount = persistedSession.accounts.find(
|
||||
a => a.did === persistedSession.currentAccount?.did,
|
||||
)
|
||||
|
||||
if (selectedAccount && selectedAccount.refreshJwt) {
|
||||
if (selectedAccount.did !== state.currentAccountDid) {
|
||||
if (selectedAccount.did !== state.currentAgentState.did) {
|
||||
logger.debug(`session: persisted onUpdate, switching accounts`, {
|
||||
from: {
|
||||
did: state.currentAccountDid,
|
||||
did: state.currentAgentState.did,
|
||||
},
|
||||
to: {
|
||||
did: selectedAccount.did,
|
||||
@@ -516,8 +499,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
*/
|
||||
// @ts-ignore we checked for `refreshJwt` above
|
||||
__globalAgent.session = selectedAccount
|
||||
// TODO: This needs a setState.
|
||||
}
|
||||
} else if (!selectedAccount && state.currentAccountDid) {
|
||||
} else if (!selectedAccount && state.currentAgentState.did) {
|
||||
logger.debug(
|
||||
`session: persisted onUpdate, logging out`,
|
||||
{},
|
||||
@@ -530,28 +514,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
* handled by `persistSession` (which nukes this accounts tokens only),
|
||||
* or by a `logout` call which nukes all accounts tokens)
|
||||
*/
|
||||
clearCurrentAccount()
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
}
|
||||
|
||||
setState(() => ({
|
||||
accounts: persistedSession.accounts,
|
||||
currentAccountDid: selectedAccount?.did,
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
})
|
||||
}, [state, setState, clearCurrentAccount, initSession])
|
||||
}, [state, setState, initSession])
|
||||
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAccountDid,
|
||||
a => a.did === state.currentAgentState.did,
|
||||
),
|
||||
isInitialLoad,
|
||||
isSwitchingAccounts,
|
||||
hasSession: !!state.currentAccountDid,
|
||||
hasSession: !!state.currentAgentState.did,
|
||||
}),
|
||||
[state, isInitialLoad, isSwitchingAccounts],
|
||||
[state],
|
||||
)
|
||||
|
||||
const api = React.useMemo(
|
||||
@@ -560,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
selectAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
}),
|
||||
@@ -571,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
selectAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
],
|
||||
|
||||
@@ -6,8 +6,6 @@ export type SessionAccount = PersistedAccount
|
||||
export type SessionStateContext = {
|
||||
accounts: SessionAccount[]
|
||||
currentAccount: SessionAccount | undefined
|
||||
isInitialLoad: boolean
|
||||
isSwitchingAccounts: boolean
|
||||
hasSession: boolean
|
||||
}
|
||||
export type SessionApiContext = {
|
||||
@@ -46,12 +44,7 @@ export type SessionApiContext = {
|
||||
*/
|
||||
clearCurrentAccount: () => void
|
||||
initSession: (account: SessionAccount) => Promise<void>
|
||||
resumeSession: (account?: SessionAccount) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
selectAccount: (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
updateCurrentAccount: (
|
||||
account: Partial<
|
||||
Pick<
|
||||
|
||||
@@ -18,6 +18,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {
|
||||
createGIFDescription,
|
||||
parseAltFromGIFDescription,
|
||||
} from '#/lib/gif-alt-text'
|
||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
@@ -59,6 +63,7 @@ import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {CharProgress} from './char-progress/CharProgress'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {GifAltText} from './GifAltText'
|
||||
import {LabelsBtn} from './labels/LabelsBtn'
|
||||
import {Gallery} from './photos/Gallery'
|
||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||
@@ -210,11 +215,25 @@ export const ComposePost = observer(function ComposePost({
|
||||
[gallery, track],
|
||||
)
|
||||
|
||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||
if (!requireAltTextEnabled) return false
|
||||
|
||||
if (gallery.needsAltText) return true
|
||||
if (extGif) {
|
||||
if (!extLink?.meta?.description) return true
|
||||
|
||||
const parsedAlt = parseAltFromGIFDescription(extLink.meta.description)
|
||||
if (!parsedAlt.isPreferred) return true
|
||||
}
|
||||
return false
|
||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||
|
||||
const onPressPublish = async () => {
|
||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
}
|
||||
if (requireAltTextEnabled && gallery.needsAltText) {
|
||||
|
||||
if (isAltTextRequiredAndMissing) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -297,10 +316,8 @@ export const ComposePost = observer(function ComposePost({
|
||||
}
|
||||
|
||||
const canPost = useMemo(
|
||||
() =>
|
||||
graphemeLength <= MAX_GRAPHEME_LENGTH &&
|
||||
(!requireAltTextEnabled || !gallery.needsAltText),
|
||||
[graphemeLength, requireAltTextEnabled, gallery.needsAltText],
|
||||
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
|
||||
[graphemeLength, isAltTextRequiredAndMissing],
|
||||
)
|
||||
const selectTextInputPlaceholder = replyTo
|
||||
? _(msg`Write your reply`)
|
||||
@@ -327,7 +344,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
image: gif.media_formats.preview.url,
|
||||
likelyType: LikelyType.HTML,
|
||||
title: gif.content_description,
|
||||
description: `ALT: ${gif.content_description}`,
|
||||
description: createGIFDescription(gif.content_description),
|
||||
},
|
||||
})
|
||||
setExtGif(gif)
|
||||
@@ -335,6 +352,26 @@ export const ComposePost = observer(function ComposePost({
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
const handleChangeGifAltText = useCallback(
|
||||
(altText: string) => {
|
||||
setExtLink(ext =>
|
||||
ext && ext.meta
|
||||
? {
|
||||
...ext,
|
||||
meta: {
|
||||
...ext.meta,
|
||||
description: createGIFDescription(
|
||||
ext.meta.title ?? '',
|
||||
altText,
|
||||
),
|
||||
},
|
||||
}
|
||||
: ext,
|
||||
)
|
||||
},
|
||||
[setExtLink],
|
||||
)
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
@@ -412,7 +449,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{requireAltTextEnabled && gallery.needsAltText && (
|
||||
{isAltTextRequiredAndMissing && (
|
||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon
|
||||
@@ -474,14 +511,21 @@ export const ComposePost = observer(function ComposePost({
|
||||
|
||||
<Gallery gallery={gallery} />
|
||||
{gallery.isEmpty && extLink && (
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onRemove={() => {
|
||||
setExtLink(undefined)
|
||||
setExtGif(undefined)
|
||||
}}
|
||||
/>
|
||||
<View style={a.relative}>
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onRemove={() => {
|
||||
setExtLink(undefined)
|
||||
setExtGif(undefined)
|
||||
}}
|
||||
/>
|
||||
<GifAltText
|
||||
link={extLink}
|
||||
gif={extGif}
|
||||
onSubmit={handleChangeGifAltText}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{quote ? (
|
||||
<View style={[s.mt5, isWeb && s.mb10]}>
|
||||
|
||||
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
|
||||
<View
|
||||
style={[
|
||||
!gif && a.mb_xl,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
{link.isLoading ? (
|
||||
<Container style={loadingStyle}>
|
||||
<Loader size="xl" />
|
||||
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
|
||||
</Container>
|
||||
) : linkInfo ? (
|
||||
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
|
||||
<ExternalLinkEmbed link={linkInfo} />
|
||||
<ExternalLinkEmbed link={linkInfo} hideAlt />
|
||||
</View>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ExternalEmbedDraft} from '#/lib/api'
|
||||
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
|
||||
import {
|
||||
EmbedPlayerParams,
|
||||
parseEmbedPlayerFromUrl,
|
||||
} from '#/lib/strings/embed-player'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {GifEmbed} from '../util/post-embeds/GifEmbed'
|
||||
import {AltTextReminder} from './photos/Gallery'
|
||||
|
||||
export function GifAltText({
|
||||
link: linkProp,
|
||||
gif,
|
||||
onSubmit,
|
||||
}: {
|
||||
link: ExternalEmbedDraft
|
||||
gif?: Gif
|
||||
onSubmit: (alt: string) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {link, params} = React.useMemo(() => {
|
||||
return {
|
||||
link: {
|
||||
title: linkProp.meta?.title ?? linkProp.uri,
|
||||
uri: linkProp.uri,
|
||||
description: linkProp.meta?.description ?? '',
|
||||
thumb: linkProp.localThumb?.path,
|
||||
},
|
||||
params: parseEmbedPlayerFromUrl(linkProp.uri),
|
||||
}
|
||||
}, [linkProp])
|
||||
|
||||
const onPressSubmit = useCallback(
|
||||
(alt: string) => {
|
||||
control.close(() => {
|
||||
onSubmit(alt)
|
||||
})
|
||||
},
|
||||
[onSubmit, control],
|
||||
)
|
||||
|
||||
if (!gif || !params) return null
|
||||
|
||||
const parsedAlt = parseAltFromGIFDescription(link.description)
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={control.open}
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: 20, left: 12},
|
||||
{borderRadius: 6},
|
||||
a.pl_xs,
|
||||
a.pr_sm,
|
||||
a.py_2xs,
|
||||
a.flex_row,
|
||||
a.gap_xs,
|
||||
a.align_center,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.75)'},
|
||||
]}>
|
||||
{parsedAlt.isPreferred ? (
|
||||
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
|
||||
) : (
|
||||
<Plus size="sm" fill={t.palette.white} />
|
||||
)}
|
||||
<Text
|
||||
style={[a.font_bold, {color: t.palette.white}]}
|
||||
accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<AltTextReminder />
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
|
||||
<Dialog.Handle />
|
||||
<AltTextInner
|
||||
onSubmit={onPressSubmit}
|
||||
link={link}
|
||||
params={params}
|
||||
initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''}
|
||||
key={link.uri}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AltTextInner({
|
||||
onSubmit,
|
||||
link,
|
||||
params,
|
||||
initialValue: initalValue,
|
||||
}: {
|
||||
onSubmit: (text: string) => void
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
initialValue: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [altText, setAltText] = useState(initalValue)
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const onPressSubmit = useCallback(() => {
|
||||
onSubmit(altText)
|
||||
}, [onSubmit, altText])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
|
||||
<View style={a.flex_col_reverse}>
|
||||
<View style={[a.mt_md, a.gap_md]}>
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Descriptive alt text</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
label={_(msg`Alt text`)}
|
||||
placeholder={link.title}
|
||||
onChangeText={text =>
|
||||
setAltText(enforceLen(text, MAX_ALT_TEXT))
|
||||
}
|
||||
value={altText}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
autoFocus
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Save`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={onPressSubmit}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
{/* below the text input to force tab order */}
|
||||
<View>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Add ALT text</Trans>
|
||||
</Text>
|
||||
<View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
|
||||
<GifEmbed link={link} params={params} hideAlt />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user