Compare commits
60 Commits
dms/errors
...
ro/gate
| Author | SHA1 | Date | |
|---|---|---|---|
| 32e17e04f5 | |||
| 55ac287d5e | |||
| 3bac0182b5 | |||
| ce2eddca8e | |||
| 0c6bf276dd | |||
| f62b0458a7 | |||
| 701e508a1a | |||
| 750a5c899b | |||
| 7d06fb9432 | |||
| f3515e2673 | |||
| 38198fdf7f | |||
| 56f713077f | |||
| 814ec2bd7f | |||
| 0910525e2e | |||
| 4fe5a869c3 | |||
| 0c41b3188a | |||
| 31a8356aef | |||
| 165fdb7049 | |||
| 37f22ca224 | |||
| b2b1c6c040 | |||
| 53d844495b | |||
| 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 | |||
| 1d8fdbcc34 | |||
| c303df5bd6 | |||
| 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
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -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"
|
||||
@@ -184,6 +186,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -255,6 +259,8 @@ jobs:
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -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,29 @@ 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:
|
||||
'NSPrivacyAccessedAPICategorySystemBootTime',
|
||||
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="M2.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 381 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 |
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
@@ -13,6 +12,9 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// time.RFC822Z, but with four digit year. used for RSS pubData.
|
||||
var FullYearRFC822Z = "02 Jan 2006 15:04 -0700"
|
||||
|
||||
type ItemGUID struct {
|
||||
XMLName xml.Name `xml:"guid"`
|
||||
Value string `xml:",chardata"`
|
||||
@@ -107,7 +109,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
|
||||
pubDate := ""
|
||||
createdAt, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
|
||||
if nil == err {
|
||||
pubDate = createdAt.Time().Format(time.RFC822Z)
|
||||
pubDate = createdAt.Time().Format(FullYearRFC822Z)
|
||||
}
|
||||
posts = append(posts, Item{
|
||||
Link: fmt.Sprintf("https://%s/profile/%s/post/%s", req.Host, pv.Handle, aturi.RecordKey().String()),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
@@ -286,7 +290,7 @@
|
||||
"\\.[jt]sx?$": "babel-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
|
||||
+24
-21
@@ -16,6 +16,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
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'
|
||||
@@ -56,7 +57,7 @@ SplashScreen.preventAutoHideAsync()
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -64,20 +65,20 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
async function onLaunch(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
await resumeSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
logger.error(`session: resume failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [initSession])
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
@@ -97,21 +98,23 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
|
||||
+22
-19
@@ -9,6 +9,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
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'
|
||||
@@ -44,17 +45,17 @@ import {listenSessionDropped} from './state/events'
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function resumeSession(account?: SessionAccount) {
|
||||
async function onLaunch(account?: SessionAccount) {
|
||||
try {
|
||||
if (account) {
|
||||
await initSession(account)
|
||||
await resumeSession(account)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
@@ -63,8 +64,8 @@ function InnerApp() {
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [initSession])
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
@@ -84,20 +85,22 @@ function InnerApp() {
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -62,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import React from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
@@ -9,6 +9,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto-labs/api'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
@@ -37,12 +38,12 @@ export function ActionsWrapper({
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = useCallback(() => {
|
||||
const open = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
|
||||
const shrink = useCallback(() => {
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
@@ -52,7 +53,7 @@ export function ActionsWrapper({
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 750}, finished => {
|
||||
scale.value = withTiming(1.05, {duration: 450}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
@@ -74,7 +75,8 @@ export function ActionsWrapper({
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}>
|
||||
onTouchEnd={shrink}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {
|
||||
useMuteConvo,
|
||||
@@ -24,6 +25,7 @@ import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Per
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
||||
|
||||
let ConvoMenu = ({
|
||||
convo,
|
||||
@@ -32,6 +34,7 @@ let ConvoMenu = ({
|
||||
control,
|
||||
hideTrigger,
|
||||
currentScreen,
|
||||
showMarkAsRead,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
@@ -39,11 +42,13 @@ let ConvoMenu = ({
|
||||
control?: Menu.MenuControlProps
|
||||
hideTrigger?: boolean
|
||||
currentScreen: 'list' | 'conversation'
|
||||
showMarkAsRead?: boolean
|
||||
}): React.ReactNode => {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const leaveConvoControl = Prompt.usePromptControl()
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
const onNavigateToProfile = useCallback(() => {
|
||||
navigation.navigate('Profile', {name: profile.did})
|
||||
@@ -107,6 +112,20 @@ let ConvoMenu = ({
|
||||
)}
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{showMarkAsRead && (
|
||||
<Menu.Item
|
||||
label={_(msg`Mark as read`)}
|
||||
onPress={() =>
|
||||
markAsRead({
|
||||
convoId: convo.id,
|
||||
})
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark as read</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Bubble} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
label={_(msg`Go to user's profile`)}
|
||||
onPress={onNavigateToProfile}>
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 {useConvo} from 'state/messages/convo'
|
||||
import {ConvoStatus} from 'state/messages/convo/types'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -33,7 +33,7 @@ export let MessageMenu = ({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const chat = useChat()
|
||||
const convo = useConvo()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
|
||||
@@ -48,14 +48,14 @@ export let MessageMenu = ({
|
||||
}, [_, message.text])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
if (chat.status !== ConvoStatus.Ready) return
|
||||
if (convo.status !== ConvoStatus.Ready) return
|
||||
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
chat
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, chat, message.id, retryDeleteControl])
|
||||
}, [_, convo, message.id, retryDeleteControl])
|
||||
|
||||
const onReport = React.useCallback(() => {
|
||||
// TODO report the message
|
||||
|
||||
@@ -3,3 +3,11 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z',
|
||||
})
|
||||
|
||||
export const Bubble_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: 'M2.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
|
||||
// along with the other version info. Useful for debugging/reporting.
|
||||
export const BUNDLE_IDENTIFIER =
|
||||
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function useAccountSwitcher() {
|
||||
const [pendingDid, setPendingDid] = useState<string | null>(null)
|
||||
const {_} = useLingui()
|
||||
const {track} = useAnalytics()
|
||||
const {initSession} = useSessionApi()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
@@ -39,7 +39,7 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await initSession(account)
|
||||
await resumeSession(account)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} else {
|
||||
@@ -57,7 +57,7 @@ export function useAccountSwitcher() {
|
||||
setPendingDid(null)
|
||||
}
|
||||
},
|
||||
[_, track, initSession, requestSwitchToAccount, pendingDid],
|
||||
[_, track, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount, pendingDid}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ export type Gate =
|
||||
| 'disable_min_shell_on_foregrounding_v3'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'dms'
|
||||
| 'reduced_onboarding_and_home_algo'
|
||||
| '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
@@ -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
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ChooseAccountForm = ({
|
||||
const {track, screen} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {initSession} = useSessionApi()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -51,7 +51,7 @@ export const ChooseAccountForm = ({
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
await initSession(account)
|
||||
await resumeSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
@@ -71,7 +71,7 @@ export const ChooseAccountForm = ({
|
||||
[
|
||||
currentAccount,
|
||||
track,
|
||||
initSession,
|
||||
resumeSession,
|
||||
pendingDid,
|
||||
onSelectAccount,
|
||||
setShowLoggedOut,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ConvoError, ConvoItem} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
@@ -18,7 +18,13 @@ export function MessageListError({
|
||||
const {_} = useLingui()
|
||||
const message = React.useMemo(() => {
|
||||
return {
|
||||
[ConvoError.HistoryFailed]: _(msg`Failed to load past messages.`),
|
||||
[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])
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ 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 {useConvo} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {ScrollProvider} from 'lib/ScrollContext'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {List} from 'view/com/util/List'
|
||||
@@ -86,7 +86,7 @@ function onScrollToIndexFailed() {
|
||||
}
|
||||
|
||||
export function MessagesList() {
|
||||
const chat = useChat()
|
||||
const convo = useConvo()
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
|
||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||
@@ -153,20 +153,20 @@ export function MessagesList() {
|
||||
// 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()
|
||||
if (convo.status === ConvoStatus.Ready && hasInitiallyScrolled) {
|
||||
convo.fetchMessageHistory()
|
||||
}
|
||||
}, [chat, hasInitiallyScrolled])
|
||||
}, [convo, hasInitiallyScrolled])
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
if (chat.status === ConvoStatus.Ready) {
|
||||
chat.sendMessage({
|
||||
if (convo.status === ConvoStatus.Ready) {
|
||||
convo.sendMessage({
|
||||
text,
|
||||
})
|
||||
}
|
||||
},
|
||||
[chat],
|
||||
[convo],
|
||||
)
|
||||
|
||||
const onScroll = React.useCallback(
|
||||
@@ -229,7 +229,7 @@ export function MessagesList() {
|
||||
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={chat.status === ConvoStatus.Ready ? chat.items : undefined}
|
||||
data={convo.items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableVirtualization={true}
|
||||
@@ -248,12 +248,9 @@ export function MessagesList() {
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<MaybeLoader
|
||||
isLoading={
|
||||
chat.status === ConvoStatus.Ready && chat.isFetchingHistory
|
||||
}
|
||||
/>
|
||||
<MaybeLoader isLoading={convo.isFetchingHistory} />
|
||||
}
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
||||
|
||||
@@ -5,21 +5,22 @@ import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
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 {ConvoProvider, useConvo} from 'state/messages/convo'
|
||||
import {ConvoStatus} from 'state/messages/convo/types'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Error} from '#/components/Error'
|
||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ClipClopGate} from '../gate'
|
||||
@@ -31,40 +32,63 @@ type Props = NativeStackScreenProps<
|
||||
export function MessagesConversationScreen({route}: Props) {
|
||||
const gate = useGate()
|
||||
const convoId = route.params.conversation
|
||||
const {setCurrentConvoId} = useCurrentConvoId()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setCurrentConvoId(convoId)
|
||||
return () => {
|
||||
setCurrentConvoId(undefined)
|
||||
}
|
||||
}, [convoId, setCurrentConvoId]),
|
||||
)
|
||||
|
||||
if (!gate('dms')) return <ClipClopGate />
|
||||
|
||||
return (
|
||||
<ChatProvider convoId={convoId}>
|
||||
<ConvoProvider convoId={convoId}>
|
||||
<Inner />
|
||||
</ChatProvider>
|
||||
</ConvoProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const chat = useChat()
|
||||
const {currentAccount} = useSession()
|
||||
const myDid = currentAccount?.did
|
||||
const convo = useConvo()
|
||||
const {_} = useLingui()
|
||||
|
||||
const otherProfile = React.useMemo(() => {
|
||||
if (chat.status !== ConvoStatus.Ready) return
|
||||
return chat.convo.members.find(m => m.did !== myDid)
|
||||
}, [chat, myDid])
|
||||
|
||||
// TODO whenever we have error messages, we should use them in here -hailey
|
||||
if (chat.status !== ConvoStatus.Ready || !otherProfile) {
|
||||
if (
|
||||
convo.status === ConvoStatus.Uninitialized ||
|
||||
convo.status === ConvoStatus.Initializing
|
||||
) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={chat.status === ConvoStatus.Error}
|
||||
/>
|
||||
<CenteredView style={a.flex_1} sideBorders>
|
||||
<Header />
|
||||
<ListMaybePlaceholder isLoading />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
if (convo.status === ConvoStatus.Error) {
|
||||
return (
|
||||
<CenteredView style={a.flex_1} sideBorders>
|
||||
<Header />
|
||||
<Error
|
||||
title={_(msg`Something went wrong`)}
|
||||
message={_(msg`We couldn't load this conversation`)}
|
||||
onRetry={() => convo.error.retry()}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Any other convo states (atm) are "ready" states
|
||||
*/
|
||||
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<CenteredView style={{flex: 1}} sideBorders>
|
||||
<Header profile={otherProfile} />
|
||||
<CenteredView style={a.flex_1} sideBorders>
|
||||
<Header profile={convo.recipients[0]} />
|
||||
<MessagesList />
|
||||
</CenteredView>
|
||||
</KeyboardProvider>
|
||||
@@ -74,13 +98,13 @@ function Inner() {
|
||||
let Header = ({
|
||||
profile,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const chat = useChat()
|
||||
const convo = useConvo()
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
@@ -129,14 +153,36 @@ let Header = ({
|
||||
<View style={{width: 30}} />
|
||||
)}
|
||||
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
|
||||
<PreviewableUserAvatar size={32} profile={profile} />
|
||||
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
|
||||
{profile.displayName}
|
||||
</Text>
|
||||
{profile ? (
|
||||
<>
|
||||
<PreviewableUserAvatar size={32} profile={profile} />
|
||||
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
|
||||
{profile.displayName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
{width: 32, height: 32},
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
{width: 120, height: 18},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.mb_2xs,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{chat.status === ConvoStatus.Ready ? (
|
||||
{convo.status === ConvoStatus.Ready && profile ? (
|
||||
<ConvoMenu
|
||||
convo={chat.convo}
|
||||
convo={convo.convo}
|
||||
profile={profile}
|
||||
onUpdateConvo={onUpdateConvo}
|
||||
currentScreen="conversation"
|
||||
|
||||
@@ -47,6 +47,7 @@ export function MessagesScreen({navigation}: Props) {
|
||||
|
||||
// TEMP
|
||||
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
|
||||
const [serviceUrlValue, setServiceUrlValue] = useState(serviceUrl)
|
||||
const hasValidServiceUrl = useMemo(() => {
|
||||
const hash = sha256(serviceUrl)
|
||||
return (
|
||||
@@ -136,13 +137,21 @@ export function MessagesScreen({navigation}: Props) {
|
||||
<TextField.LabelText>Service URL</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
value={serviceUrl}
|
||||
onChangeText={text => setServiceUrl(text)}
|
||||
value={serviceUrlValue}
|
||||
onChangeText={text => setServiceUrlValue(text)}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
label="https://"
|
||||
/>
|
||||
</TextField.Root>
|
||||
<Button
|
||||
label="Set Service URL"
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => setServiceUrl(serviceUrlValue)}>
|
||||
<ButtonText>Set</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
@@ -266,7 +275,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
a.pl_md,
|
||||
a.py_sm,
|
||||
a.gap_md,
|
||||
a.pr_2xl,
|
||||
a.pr_xl,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View pointerEvents="none">
|
||||
@@ -331,6 +340,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
// tricky because it captures the mouse event
|
||||
hideTrigger
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -35,7 +35,14 @@ export function DmServiceUrlProvider({children}: {children: React.ReactNode}) {
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
const v = await getItem()
|
||||
setServiceUrl(v ?? '')
|
||||
try {
|
||||
if (v) {
|
||||
new URL(v)
|
||||
setServiceUrl(v)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Invalid service URL stored in async storage:', v)
|
||||
}
|
||||
})()
|
||||
}, [getItem])
|
||||
|
||||
|
||||
@@ -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,22 +1,33 @@
|
||||
import React from 'react'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
|
||||
import {StepInterests} from '#/screens/Onboarding/StepInterests'
|
||||
import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
|
||||
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
|
||||
import {
|
||||
Context,
|
||||
initialState,
|
||||
initialStateReduced,
|
||||
reducer,
|
||||
reducerReduced,
|
||||
} from '#/screens/Onboarding/state'
|
||||
import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
|
||||
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
|
||||
import {StepFinished} from '#/screens/Onboarding/StepFinished'
|
||||
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
|
||||
import {StepInterests} from '#/screens/Onboarding/StepInterests'
|
||||
import {StepModeration} from '#/screens/Onboarding/StepModeration'
|
||||
import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
|
||||
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export function Onboarding() {
|
||||
const {_} = useLingui()
|
||||
const [state, dispatch] = React.useReducer(reducer, {...initialState})
|
||||
const gate = useGate()
|
||||
const isReducedOnboardingEnabled = gate('reduced_onboarding_and_home_algo')
|
||||
const [state, dispatch] = React.useReducer(
|
||||
isReducedOnboardingEnabled ? reducerReduced : reducer,
|
||||
isReducedOnboardingEnabled ? {...initialStateReduced} : {...initialState},
|
||||
)
|
||||
|
||||
const interestsDisplayNames = React.useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -237,3 +237,136 @@ export function reducer(
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export const initialStateReduced: OnboardingState = {
|
||||
hasPrev: false,
|
||||
totalSteps: 7,
|
||||
activeStep: 'interests',
|
||||
activeStepIndex: 1,
|
||||
|
||||
interestsStepResults: {
|
||||
selectedInterests: [],
|
||||
apiResponse: {
|
||||
interests: [],
|
||||
suggestedAccountDids: {},
|
||||
suggestedFeedUris: {},
|
||||
},
|
||||
},
|
||||
suggestedAccountsStepResults: {
|
||||
accountDids: [],
|
||||
},
|
||||
algoFeedsStepResults: {
|
||||
feedUris: [],
|
||||
},
|
||||
topicalFeedsStepResults: {
|
||||
feedUris: [],
|
||||
},
|
||||
}
|
||||
|
||||
export function reducerReduced(
|
||||
s: OnboardingState,
|
||||
a: OnboardingAction,
|
||||
): OnboardingState {
|
||||
let next = {...s}
|
||||
|
||||
switch (a.type) {
|
||||
case 'next': {
|
||||
if (s.activeStep === 'interests') {
|
||||
next.activeStep = 'suggestedAccounts'
|
||||
next.activeStepIndex = 2
|
||||
} else if (s.activeStep === 'suggestedAccounts') {
|
||||
next.activeStep = 'followingFeed'
|
||||
next.activeStepIndex = 3
|
||||
} else if (s.activeStep === 'followingFeed') {
|
||||
next.activeStep = 'algoFeeds'
|
||||
next.activeStepIndex = 4
|
||||
} else if (s.activeStep === 'algoFeeds') {
|
||||
next.activeStep = 'topicalFeeds'
|
||||
next.activeStepIndex = 5
|
||||
} else if (s.activeStep === 'topicalFeeds') {
|
||||
next.activeStep = 'moderation'
|
||||
next.activeStepIndex = 6
|
||||
} else if (s.activeStep === 'moderation') {
|
||||
next.activeStep = 'finished'
|
||||
next.activeStepIndex = 7
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prev': {
|
||||
if (s.activeStep === 'suggestedAccounts') {
|
||||
next.activeStep = 'interests'
|
||||
next.activeStepIndex = 1
|
||||
} else if (s.activeStep === 'followingFeed') {
|
||||
next.activeStep = 'suggestedAccounts'
|
||||
next.activeStepIndex = 2
|
||||
} else if (s.activeStep === 'algoFeeds') {
|
||||
next.activeStep = 'followingFeed'
|
||||
next.activeStepIndex = 3
|
||||
} else if (s.activeStep === 'topicalFeeds') {
|
||||
next.activeStep = 'algoFeeds'
|
||||
next.activeStepIndex = 4
|
||||
} else if (s.activeStep === 'moderation') {
|
||||
next.activeStep = 'topicalFeeds'
|
||||
next.activeStepIndex = 5
|
||||
} else if (s.activeStep === 'finished') {
|
||||
next.activeStep = 'moderation'
|
||||
next.activeStepIndex = 6
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'finish': {
|
||||
next = initialState
|
||||
break
|
||||
}
|
||||
case 'setInterestsStepResults': {
|
||||
next.interestsStepResults = {
|
||||
selectedInterests: a.selectedInterests,
|
||||
apiResponse: a.apiResponse,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'setSuggestedAccountsStepResults': {
|
||||
next.suggestedAccountsStepResults = {
|
||||
accountDids: next.suggestedAccountsStepResults.accountDids.concat(
|
||||
a.accountDids,
|
||||
),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'setAlgoFeedsStepResults': {
|
||||
next.algoFeedsStepResults = {
|
||||
feedUris: a.feedUris,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'setTopicalFeedsStepResults': {
|
||||
next.topicalFeedsStepResults = {
|
||||
feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
...next,
|
||||
hasPrev: next.activeStep !== 'interests',
|
||||
}
|
||||
|
||||
logger.debug(`onboarding`, {
|
||||
hasPrev: state.hasPrev,
|
||||
activeStep: state.activeStep,
|
||||
activeStepIndex: state.activeStepIndex,
|
||||
interestsStepResults: {
|
||||
selectedInterests: state.interestsStepResults.selectedInterests,
|
||||
},
|
||||
suggestedAccountsStepResults: state.suggestedAccountsStepResults,
|
||||
algoFeedsStepResults: state.algoFeedsStepResults,
|
||||
topicalFeedsStepResults: state.topicalFeedsStepResults,
|
||||
})
|
||||
|
||||
if (s.activeStep !== state.activeStep) {
|
||||
logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -8,16 +8,11 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
|
||||
import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
DEFAULT_PROD_FEEDS,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
useSetSaveFeedsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
|
||||
@@ -207,8 +202,6 @@ export function useSubmitSignup({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {createAccount} = useSessionApi()
|
||||
const {mutateAsync: setBirthDate} = usePreferencesSetBirthDateMutation()
|
||||
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
return useCallback(
|
||||
@@ -265,13 +258,10 @@ export function useSubmitSignup({
|
||||
email: state.email,
|
||||
handle: createFullHandle(state.handle, state.userDomain),
|
||||
password: state.password,
|
||||
birthDate: state.dateOfBirth,
|
||||
inviteCode: state.inviteCode.trim(),
|
||||
verificationCode: verificationCode,
|
||||
})
|
||||
await setBirthDate({birthDate: state.dateOfBirth})
|
||||
if (IS_PROD_SERVICE(state.serviceUrl)) {
|
||||
setSavedFeeds(DEFAULT_PROD_FEEDS)
|
||||
}
|
||||
} catch (e: any) {
|
||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
||||
let errMsg = e.toString()
|
||||
@@ -314,8 +304,6 @@ export function useSubmitSignup({
|
||||
_,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
setBirthDate,
|
||||
setSavedFeeds,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,19 @@
|
||||
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`, () => {
|
||||
|
||||
@@ -2,151 +2,32 @@ import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {
|
||||
BskyAgent,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyConvoGetLog,
|
||||
ChatBskyConvoSendMessage,
|
||||
} from '@atproto-labs/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
ACTIVE_POLL_INTERVAL,
|
||||
BACKGROUND_POLL_INTERVAL,
|
||||
} from '#/state/messages/convo/const'
|
||||
import {
|
||||
ConvoDispatch,
|
||||
ConvoDispatchEvent,
|
||||
ConvoErrorCode,
|
||||
ConvoItem,
|
||||
ConvoItemError,
|
||||
ConvoParams,
|
||||
ConvoState,
|
||||
ConvoStatus,
|
||||
} from '#/state/messages/convo/types'
|
||||
import {MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {MessagesEventBusError} from '#/state/messages/events/types'
|
||||
|
||||
export type ConvoParams = {
|
||||
convoId: string
|
||||
agent: BskyAgent
|
||||
__tempFromUserDid: string
|
||||
}
|
||||
|
||||
export enum ConvoStatus {
|
||||
Uninitialized = 'uninitialized',
|
||||
Initializing = 'initializing',
|
||||
Resuming = 'resuming',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Backgrounded = 'backgrounded',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum ConvoError {
|
||||
HistoryFailed = 'historyFailed',
|
||||
}
|
||||
|
||||
export type ConvoItem =
|
||||
| {
|
||||
type: 'message' | 'pending-message'
|
||||
key: string
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
nextMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
}
|
||||
| {
|
||||
type: 'deleted-message'
|
||||
key: string
|
||||
message: ChatBskyConvoDefs.DeletedMessageView
|
||||
nextMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
}
|
||||
| {
|
||||
type: 'pending-retry'
|
||||
key: string
|
||||
retry: () => void
|
||||
}
|
||||
| {
|
||||
type: 'error-recoverable'
|
||||
key: string
|
||||
code: ConvoError
|
||||
retry: () => void
|
||||
}
|
||||
| {
|
||||
type: 'error-fatal'
|
||||
code: ConvoError
|
||||
key: string
|
||||
}
|
||||
|
||||
export type ConvoState =
|
||||
| {
|
||||
status: ConvoStatus.Uninitialized
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Initializing
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Ready
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => void
|
||||
fetchMessageHistory: () => void
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Suspended
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Backgrounded
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Resuming
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Error
|
||||
items: []
|
||||
convo: undefined
|
||||
error: any
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
|
||||
const ACTIVE_POLL_INTERVAL = 2e3
|
||||
const BACKGROUND_POLL_INTERVAL = 10e3
|
||||
// TODO temporary
|
||||
let DEBUG_ACTIVE_CHAT: string | undefined
|
||||
|
||||
export function isConvoItemMessage(
|
||||
item: ConvoItem,
|
||||
@@ -160,15 +41,23 @@ export function isConvoItemMessage(
|
||||
}
|
||||
|
||||
export class Convo {
|
||||
private id: string
|
||||
|
||||
private agent: BskyAgent
|
||||
private events: MessagesEventBus
|
||||
private __tempFromUserDid: string
|
||||
|
||||
private pollInterval = ACTIVE_POLL_INTERVAL
|
||||
private status: ConvoStatus = ConvoStatus.Uninitialized
|
||||
private error: any
|
||||
private historyCursor: string | undefined | null = undefined
|
||||
private error:
|
||||
| {
|
||||
code: ConvoErrorCode
|
||||
exception?: Error
|
||||
retry: () => void
|
||||
}
|
||||
| undefined
|
||||
private oldestRev: string | undefined | null = undefined
|
||||
private isFetchingHistory = false
|
||||
private eventsCursor: string | undefined = undefined
|
||||
private latestRev: string | undefined = undefined
|
||||
|
||||
private pastMessages: Map<
|
||||
string,
|
||||
@@ -186,17 +75,19 @@ export class Convo {
|
||||
private footerItems: Map<string, ConvoItem> = new Map()
|
||||
private headerItems: Map<string, ConvoItem> = new Map()
|
||||
|
||||
private pendingEventIngestion: Promise<void> | undefined
|
||||
private isProcessingPendingMessages = false
|
||||
|
||||
convoId: string
|
||||
convo: ChatBskyConvoDefs.ConvoView | undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined
|
||||
snapshot: ConvoState | undefined
|
||||
|
||||
constructor(params: ConvoParams) {
|
||||
this.id = nanoid(3)
|
||||
this.convoId = params.convoId
|
||||
this.agent = params.agent
|
||||
this.events = params.events
|
||||
this.__tempFromUserDid = params.__tempFromUserDid
|
||||
|
||||
this.subscribe = this.subscribe.bind(this)
|
||||
@@ -204,6 +95,23 @@ export class Convo {
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.deleteMessage = this.deleteMessage.bind(this)
|
||||
this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
|
||||
this.ingestFirehose = this.ingestFirehose.bind(this)
|
||||
this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
|
||||
this.onFirehoseError = this.onFirehoseError.bind(this)
|
||||
|
||||
if (DEBUG_ACTIVE_CHAT) {
|
||||
logger.error(`Convo: another chat was already active`, {
|
||||
convoId: this.convoId,
|
||||
})
|
||||
} else {
|
||||
DEBUG_ACTIVE_CHAT = this.convoId
|
||||
}
|
||||
|
||||
this.events.trailConvo(this.convoId, events => {
|
||||
this.ingestFirehose(events)
|
||||
})
|
||||
this.events.onConnect(this.onFirehoseConnect)
|
||||
this.events.onError(this.onFirehoseError)
|
||||
}
|
||||
|
||||
private commit() {
|
||||
@@ -226,7 +134,7 @@ export class Convo {
|
||||
|
||||
getSnapshot(): ConvoState {
|
||||
if (!this.snapshot) this.snapshot = this.generateSnapshot()
|
||||
logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo)
|
||||
// logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
@@ -238,6 +146,8 @@ export class Convo {
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: undefined,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: this.isFetchingHistory,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
@@ -246,13 +156,14 @@ export class Convo {
|
||||
}
|
||||
case ConvoStatus.Suspended:
|
||||
case ConvoStatus.Backgrounded:
|
||||
case ConvoStatus.Resuming:
|
||||
case ConvoStatus.Ready: {
|
||||
return {
|
||||
status: this.status,
|
||||
items: this.getItems(),
|
||||
convo: this.convo!,
|
||||
error: undefined,
|
||||
sender: this.sender!,
|
||||
recipients: this.recipients!,
|
||||
isFetchingHistory: this.isFetchingHistory,
|
||||
deleteMessage: this.deleteMessage,
|
||||
sendMessage: this.sendMessage,
|
||||
@@ -265,6 +176,8 @@ export class Convo {
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: this.error,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: false,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
@@ -277,6 +190,8 @@ export class Convo {
|
||||
items: [],
|
||||
convo: undefined,
|
||||
error: undefined,
|
||||
sender: undefined,
|
||||
recipients: undefined,
|
||||
isFetchingHistory: false,
|
||||
deleteMessage: undefined,
|
||||
sendMessage: undefined,
|
||||
@@ -286,96 +201,324 @@ export class Convo {
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
logger.debug('Convo: init', {}, logger.DebugContext.convo)
|
||||
dispatch(action: ConvoDispatch) {
|
||||
const prevStatus = this.status
|
||||
|
||||
if (this.status === ConvoStatus.Uninitialized) {
|
||||
try {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.commit()
|
||||
|
||||
await this.refreshConvo()
|
||||
this.status = ConvoStatus.Ready
|
||||
this.commit()
|
||||
|
||||
await this.fetchMessageHistory()
|
||||
|
||||
this.pollEvents()
|
||||
} catch (e) {
|
||||
this.error = e
|
||||
this.status = ConvoStatus.Error
|
||||
this.commit()
|
||||
switch (this.status) {
|
||||
case ConvoStatus.Uninitialized: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Init: {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.setup()
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Convo: cannot init from ${this.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
async resume() {
|
||||
logger.debug('Convo: resume', {}, logger.DebugContext.convo)
|
||||
|
||||
if (
|
||||
this.status === ConvoStatus.Suspended ||
|
||||
this.status === ConvoStatus.Backgrounded
|
||||
) {
|
||||
try {
|
||||
this.status = ConvoStatus.Resuming
|
||||
this.commit()
|
||||
|
||||
await this.refreshConvo()
|
||||
this.status = ConvoStatus.Ready
|
||||
this.commit()
|
||||
|
||||
await this.fetchMessageHistory()
|
||||
|
||||
this.pollInterval = ACTIVE_POLL_INTERVAL
|
||||
this.pollEvents()
|
||||
} catch (e) {
|
||||
// TODO handle errors in one place
|
||||
this.error = e
|
||||
this.status = ConvoStatus.Error
|
||||
this.commit()
|
||||
case ConvoStatus.Initializing: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Ready: {
|
||||
this.status = ConvoStatus.Ready
|
||||
this.fetchMessageHistory()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Background: {
|
||||
this.status = ConvoStatus.Backgrounded
|
||||
this.fetchMessageHistory()
|
||||
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Suspend: {
|
||||
this.status = ConvoStatus.Suspended
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Error: {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = action.payload
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Convo: cannot resume from ${this.status}`)
|
||||
case ConvoStatus.Ready: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Resume: {
|
||||
this.refreshConvo()
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Background: {
|
||||
this.status = ConvoStatus.Backgrounded
|
||||
this.requestPollInterval(BACKGROUND_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Suspend: {
|
||||
this.status = ConvoStatus.Suspended
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Error: {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = action.payload
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConvoStatus.Backgrounded: {
|
||||
switch (action.event) {
|
||||
// TODO truncate history if needed
|
||||
case ConvoDispatchEvent.Resume: {
|
||||
if (this.convo) {
|
||||
this.status = ConvoStatus.Ready
|
||||
this.refreshConvo()
|
||||
} else {
|
||||
this.status = ConvoStatus.Initializing
|
||||
this.setup()
|
||||
}
|
||||
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Suspend: {
|
||||
this.status = ConvoStatus.Suspended
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Error: {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = action.payload
|
||||
this.withdrawRequestedPollInterval()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConvoStatus.Suspended: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Init: {
|
||||
this.reset()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Resume: {
|
||||
this.reset()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Error: {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = action.payload
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConvoStatus.Error: {
|
||||
switch (action.event) {
|
||||
case ConvoDispatchEvent.Init: {
|
||||
this.reset()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Resume: {
|
||||
this.reset()
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Suspend: {
|
||||
this.status = ConvoStatus.Suspended
|
||||
break
|
||||
}
|
||||
case ConvoDispatchEvent.Error: {
|
||||
this.status = ConvoStatus.Error
|
||||
this.error = action.payload
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async background() {
|
||||
logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo)
|
||||
this.status = ConvoStatus.Backgrounded
|
||||
this.pollInterval = BACKGROUND_POLL_INTERVAL
|
||||
logger.debug(
|
||||
`Convo: dispatch '${action.event}'`,
|
||||
{
|
||||
id: this.id,
|
||||
prev: prevStatus,
|
||||
next: this.status,
|
||||
},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
|
||||
this.commit()
|
||||
}
|
||||
|
||||
async suspend() {
|
||||
logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
|
||||
this.status = ConvoStatus.Suspended
|
||||
this.commit()
|
||||
private reset() {
|
||||
this.convo = undefined
|
||||
this.sender = undefined
|
||||
this.recipients = undefined
|
||||
this.snapshot = undefined
|
||||
|
||||
this.status = ConvoStatus.Uninitialized
|
||||
this.error = undefined
|
||||
this.oldestRev = undefined
|
||||
this.latestRev = undefined
|
||||
|
||||
this.pastMessages = new Map()
|
||||
this.newMessages = new Map()
|
||||
this.pendingMessages = new Map()
|
||||
this.deletedMessages = new Set()
|
||||
this.footerItems = new Map()
|
||||
this.headerItems = new Map()
|
||||
|
||||
this.dispatch({event: ConvoDispatchEvent.Init})
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
try {
|
||||
const {convo, sender, recipients} = await this.fetchConvo()
|
||||
|
||||
this.convo = convo
|
||||
this.sender = sender
|
||||
this.recipients = recipients
|
||||
|
||||
/*
|
||||
* Some validation prior to `Ready` status
|
||||
*/
|
||||
if (!this.convo) {
|
||||
throw new Error('Convo: could not find convo')
|
||||
}
|
||||
if (!this.sender) {
|
||||
throw new Error('Convo: could not find sender in convo')
|
||||
}
|
||||
if (!this.recipients) {
|
||||
throw new Error('Convo: could not find recipients in convo')
|
||||
}
|
||||
|
||||
// await new Promise(y => setTimeout(y, 2000))
|
||||
// throw new Error('UNCOMMENT TO TEST INIT FAILURE')
|
||||
this.dispatch({event: ConvoDispatchEvent.Ready})
|
||||
} catch (e: any) {
|
||||
logger.error('Convo: setup() failed')
|
||||
|
||||
this.dispatch({
|
||||
event: ConvoDispatchEvent.Error,
|
||||
payload: {
|
||||
exception: e,
|
||||
code: ConvoErrorCode.InitFailed,
|
||||
retry: () => {
|
||||
this.reset()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.dispatch({event: ConvoDispatchEvent.Init})
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.dispatch({event: ConvoDispatchEvent.Resume})
|
||||
}
|
||||
|
||||
background() {
|
||||
this.dispatch({event: ConvoDispatchEvent.Background})
|
||||
}
|
||||
|
||||
suspend() {
|
||||
this.dispatch({event: ConvoDispatchEvent.Suspend})
|
||||
DEBUG_ACTIVE_CHAT = undefined
|
||||
}
|
||||
|
||||
private requestedPollInterval: (() => void) | undefined
|
||||
private requestPollInterval(interval: number) {
|
||||
this.withdrawRequestedPollInterval()
|
||||
this.requestedPollInterval = this.events.requestPollInterval(interval)
|
||||
}
|
||||
private withdrawRequestedPollInterval() {
|
||||
if (this.requestedPollInterval) {
|
||||
this.requestedPollInterval()
|
||||
}
|
||||
}
|
||||
|
||||
private pendingFetchConvo:
|
||||
| Promise<{
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
sender: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
}>
|
||||
| undefined
|
||||
async fetchConvo() {
|
||||
if (this.pendingFetchConvo) return this.pendingFetchConvo
|
||||
|
||||
this.pendingFetchConvo = new Promise<{
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
sender: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
}>(async (resolve, reject) => {
|
||||
try {
|
||||
const response = await this.agent.api.chat.bsky.convo.getConvo(
|
||||
{
|
||||
convoId: this.convoId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const convo = response.data.convo
|
||||
|
||||
resolve({
|
||||
convo,
|
||||
sender: convo.members.find(m => m.did === this.__tempFromUserDid),
|
||||
recipients: convo.members.filter(
|
||||
m => m.did !== this.__tempFromUserDid,
|
||||
),
|
||||
})
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
} finally {
|
||||
this.pendingFetchConvo = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return this.pendingFetchConvo
|
||||
}
|
||||
|
||||
async refreshConvo() {
|
||||
const response = await this.agent.api.chat.bsky.convo.getConvo(
|
||||
{
|
||||
convoId: this.convoId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
try {
|
||||
const {convo, sender, recipients} = await this.fetchConvo()
|
||||
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
|
||||
this.convo = convo || this.convo
|
||||
this.sender = sender || this.sender
|
||||
this.recipients = recipients || this.recipients
|
||||
} catch (e: any) {
|
||||
logger.error(`Convo: failed to refresh convo`)
|
||||
|
||||
this.footerItems.set(ConvoItemError.Network, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoItemError.Network,
|
||||
code: ConvoItemError.Network,
|
||||
retry: () => {
|
||||
this.footerItems.delete(ConvoItemError.Network)
|
||||
this.resume()
|
||||
},
|
||||
},
|
||||
)
|
||||
this.convo = response.data.convo
|
||||
this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid)
|
||||
})
|
||||
this.commit()
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMessageHistory() {
|
||||
logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo)
|
||||
|
||||
/*
|
||||
* If historyCursor is null, we've fetched all history.
|
||||
* If oldestRev is null, we've fetched all history.
|
||||
*/
|
||||
if (this.historyCursor === null) return
|
||||
if (this.oldestRev === null) return
|
||||
|
||||
/*
|
||||
* Don't fetch again if a fetch is already in progress
|
||||
@@ -386,7 +529,7 @@ export class Convo {
|
||||
* If we've rendered a retry state for history fetching, exit. Upon retry,
|
||||
* this will be removed and we'll try again.
|
||||
*/
|
||||
if (this.headerItems.has(ConvoError.HistoryFailed)) return
|
||||
if (this.headerItems.has(ConvoItemError.HistoryFailed)) return
|
||||
|
||||
try {
|
||||
this.isFetchingHistory = true
|
||||
@@ -403,7 +546,7 @@ export class Convo {
|
||||
|
||||
const response = await this.agent.api.chat.bsky.convo.getMessages(
|
||||
{
|
||||
cursor: this.historyCursor,
|
||||
cursor: this.oldestRev,
|
||||
convoId: this.convoId,
|
||||
limit: isNative ? 25 : 50,
|
||||
},
|
||||
@@ -415,32 +558,33 @@ export class Convo {
|
||||
)
|
||||
const {cursor, messages} = response.data
|
||||
|
||||
this.historyCursor = cursor ?? null
|
||||
this.oldestRev = cursor ?? null
|
||||
|
||||
for (const message of messages) {
|
||||
if (
|
||||
ChatBskyConvoDefs.isMessageView(message) ||
|
||||
ChatBskyConvoDefs.isDeletedMessageView(message)
|
||||
) {
|
||||
this.pastMessages.set(message.id, message)
|
||||
|
||||
// set to latest rev
|
||||
if (
|
||||
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
|
||||
) {
|
||||
this.eventsCursor = message.rev
|
||||
/*
|
||||
* If this message is already in new messages, it was added by the
|
||||
* firehose ingestion, and we can safely overwrite it. This trusts
|
||||
* the server on ordering, and keeps it in sync.
|
||||
*/
|
||||
if (this.newMessages.has(message.id)) {
|
||||
this.newMessages.delete(message.id)
|
||||
}
|
||||
this.pastMessages.set(message.id, message)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Convo: failed to fetch message history')
|
||||
|
||||
this.headerItems.set(ConvoError.HistoryFailed, {
|
||||
this.headerItems.set(ConvoItemError.HistoryFailed, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoError.HistoryFailed,
|
||||
code: ConvoError.HistoryFailed,
|
||||
key: ConvoItemError.HistoryFailed,
|
||||
code: ConvoItemError.HistoryFailed,
|
||||
retry: () => {
|
||||
this.headerItems.delete(ConvoError.HistoryFailed)
|
||||
this.headerItems.delete(ConvoItemError.HistoryFailed)
|
||||
this.fetchMessageHistory()
|
||||
},
|
||||
})
|
||||
@@ -450,85 +594,94 @@ export class Convo {
|
||||
}
|
||||
}
|
||||
|
||||
private async pollEvents() {
|
||||
if (
|
||||
this.status === ConvoStatus.Ready ||
|
||||
this.status === ConvoStatus.Backgrounded
|
||||
) {
|
||||
if (this.pendingEventIngestion) return
|
||||
|
||||
setTimeout(async () => {
|
||||
this.pendingEventIngestion = this.ingestLatestEvents()
|
||||
await this.pendingEventIngestion
|
||||
this.pendingEventIngestion = undefined
|
||||
this.pollEvents()
|
||||
}, this.pollInterval)
|
||||
}
|
||||
onFirehoseConnect() {
|
||||
this.footerItems.delete(ConvoItemError.PollFailed)
|
||||
this.commit()
|
||||
}
|
||||
|
||||
async ingestLatestEvents() {
|
||||
const response = await this.agent.api.chat.bsky.convo.getLog(
|
||||
{
|
||||
cursor: this.eventsCursor,
|
||||
onFirehoseError(error?: MessagesEventBusError) {
|
||||
this.footerItems.set(ConvoItemError.PollFailed, {
|
||||
type: 'error-recoverable',
|
||||
key: ConvoItemError.PollFailed,
|
||||
code: ConvoItemError.PollFailed,
|
||||
retry: () => {
|
||||
this.footerItems.delete(ConvoItemError.PollFailed)
|
||||
this.commit()
|
||||
error?.retry()
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
const {logs} = response.data
|
||||
})
|
||||
this.commit()
|
||||
}
|
||||
|
||||
ingestFirehose(events: ChatBskyConvoGetLog.OutputSchema['logs']) {
|
||||
let needsCommit = false
|
||||
|
||||
for (const log of 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 log.rev === 'string') {
|
||||
if (typeof ev.rev === 'string') {
|
||||
const isUninitialized = !this.latestRev
|
||||
const isNewEvent = this.latestRev && ev.rev > this.latestRev
|
||||
|
||||
/*
|
||||
* We received an event prior to fetching any history, so we can safely
|
||||
* use this as the initial history cursor
|
||||
*/
|
||||
if (this.oldestRev === undefined && isUninitialized) {
|
||||
this.oldestRev = ev.rev
|
||||
}
|
||||
|
||||
/*
|
||||
* We only care about new events
|
||||
*/
|
||||
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
|
||||
if (isNewEvent || isUninitialized) {
|
||||
/*
|
||||
* Update rev regardless of if it's a log type we care about or not
|
||||
* Update rev regardless of if it's a ev type we care about or not
|
||||
*/
|
||||
this.eventsCursor = log.rev
|
||||
this.latestRev = ev.rev
|
||||
|
||||
/*
|
||||
* This is VERY important. We don't want to insert any messages from
|
||||
* your other chats.
|
||||
*/
|
||||
if (log.convoId !== this.convoId) continue
|
||||
if (ev.convoId !== this.convoId) continue
|
||||
|
||||
if (
|
||||
ChatBskyConvoDefs.isLogCreateMessage(log) &&
|
||||
ChatBskyConvoDefs.isMessageView(log.message)
|
||||
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
|
||||
ChatBskyConvoDefs.isMessageView(ev.message)
|
||||
) {
|
||||
if (this.newMessages.has(log.message.id)) {
|
||||
// Trust the log as the source of truth on ordering
|
||||
this.newMessages.delete(log.message.id)
|
||||
/**
|
||||
* If this message is already in new messages, it was added by our
|
||||
* sending logic, and is based on client-ordering. When we receive
|
||||
* the "commited" event from the log, we should replace this
|
||||
* reference and re-insert in order to respect the order we receied
|
||||
* from the log.
|
||||
*/
|
||||
if (this.newMessages.has(ev.message.id)) {
|
||||
this.newMessages.delete(ev.message.id)
|
||||
}
|
||||
this.newMessages.set(log.message.id, log.message)
|
||||
this.newMessages.set(ev.message.id, ev.message)
|
||||
needsCommit = true
|
||||
} else if (
|
||||
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
|
||||
ChatBskyConvoDefs.isDeletedMessageView(log.message)
|
||||
ChatBskyConvoDefs.isLogDeleteMessage(ev) &&
|
||||
ChatBskyConvoDefs.isDeletedMessageView(ev.message)
|
||||
) {
|
||||
/*
|
||||
* Update if we have this in state. If we don't, don't worry about it.
|
||||
*/
|
||||
if (this.pastMessages.has(log.message.id)) {
|
||||
// TODO check for other storage spots
|
||||
if (this.pastMessages.has(ev.message.id)) {
|
||||
/*
|
||||
* For now, we remove deleted messages from the thread, if we receive one.
|
||||
*
|
||||
* To support them, it'd look something like this:
|
||||
* this.pastMessages.set(log.message.id, log.message)
|
||||
* this.pastMessages.set(ev.message.id, ev.message)
|
||||
*/
|
||||
this.pastMessages.delete(log.message.id)
|
||||
this.newMessages.delete(log.message.id)
|
||||
this.deletedMessages.delete(log.message.id)
|
||||
this.pastMessages.delete(ev.message.id)
|
||||
this.newMessages.delete(ev.message.id)
|
||||
this.deletedMessages.delete(ev.message.id)
|
||||
needsCommit = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ACTIVE_POLL_INTERVAL = 1e3
|
||||
export const BACKGROUND_POLL_INTERVAL = 5e3
|
||||
@@ -0,0 +1,78 @@
|
||||
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} from '#/state/messages/convo/agent'
|
||||
import {ConvoParams, ConvoState} from '#/state/messages/convo/types'
|
||||
import {useMessagesEventBus} from '#/state/messages/events'
|
||||
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
|
||||
const ChatContext = React.createContext<ConvoState | null>(null)
|
||||
|
||||
export function useConvo() {
|
||||
const ctx = useContext(ChatContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useConvo must be used within a ConvoProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ConvoProvider({
|
||||
children,
|
||||
convoId,
|
||||
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
|
||||
const isScreenFocused = useIsFocused()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const {getAgent} = useAgent()
|
||||
const events = useMessagesEventBus()
|
||||
const [convo] = useState(
|
||||
() =>
|
||||
new Convo({
|
||||
convoId,
|
||||
agent: new BskyAgent({
|
||||
service: serviceUrl,
|
||||
}),
|
||||
events,
|
||||
__tempFromUserDid: getAgent().session?.did!,
|
||||
}),
|
||||
)
|
||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
convo.resume()
|
||||
markAsRead({convoId})
|
||||
|
||||
return () => {
|
||||
convo.background()
|
||||
markAsRead({convoId})
|
||||
}
|
||||
}, [convo, convoId, markAsRead]),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: string) => {
|
||||
if (isScreenFocused) {
|
||||
if (nextAppState === 'active') {
|
||||
convo.resume()
|
||||
} else {
|
||||
convo.background()
|
||||
}
|
||||
|
||||
markAsRead({convoId})
|
||||
}
|
||||
}
|
||||
|
||||
const sub = AppState.addEventListener('change', handleAppStateChange)
|
||||
|
||||
return () => {
|
||||
sub.remove()
|
||||
}
|
||||
}, [convoId, convo, isScreenFocused, markAsRead])
|
||||
|
||||
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {
|
||||
BskyAgent,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyConvoSendMessage,
|
||||
} from '@atproto-labs/api'
|
||||
|
||||
import {MessagesEventBus} from '#/state/messages/events/agent'
|
||||
|
||||
export type ConvoParams = {
|
||||
convoId: string
|
||||
agent: BskyAgent
|
||||
events: MessagesEventBus
|
||||
__tempFromUserDid: string
|
||||
}
|
||||
|
||||
export enum ConvoStatus {
|
||||
Uninitialized = 'uninitialized',
|
||||
Initializing = 'initializing',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Backgrounded = 'backgrounded',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum ConvoItemError {
|
||||
HistoryFailed = 'historyFailed',
|
||||
PollFailed = 'pollFailed',
|
||||
Network = 'network',
|
||||
}
|
||||
|
||||
export enum ConvoErrorCode {
|
||||
InitFailed = 'initFailed',
|
||||
}
|
||||
|
||||
export type ConvoError = {
|
||||
code: ConvoErrorCode
|
||||
exception?: Error
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export enum ConvoDispatchEvent {
|
||||
Init = 'init',
|
||||
Ready = 'ready',
|
||||
Resume = 'resume',
|
||||
Background = 'background',
|
||||
Suspend = 'suspend',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type ConvoDispatch =
|
||||
| {
|
||||
event: ConvoDispatchEvent.Init
|
||||
}
|
||||
| {
|
||||
event: ConvoDispatchEvent.Ready
|
||||
}
|
||||
| {
|
||||
event: ConvoDispatchEvent.Resume
|
||||
}
|
||||
| {
|
||||
event: ConvoDispatchEvent.Background
|
||||
}
|
||||
| {
|
||||
event: ConvoDispatchEvent.Suspend
|
||||
}
|
||||
| {
|
||||
event: ConvoDispatchEvent.Error
|
||||
payload: ConvoError
|
||||
}
|
||||
|
||||
export type ConvoItem =
|
||||
| {
|
||||
type: 'message' | 'pending-message'
|
||||
key: string
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
nextMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
}
|
||||
| {
|
||||
type: 'deleted-message'
|
||||
key: string
|
||||
message: ChatBskyConvoDefs.DeletedMessageView
|
||||
nextMessage:
|
||||
| ChatBskyConvoDefs.MessageView
|
||||
| ChatBskyConvoDefs.DeletedMessageView
|
||||
| null
|
||||
}
|
||||
| {
|
||||
type: 'pending-retry'
|
||||
key: string
|
||||
retry: () => void
|
||||
}
|
||||
| {
|
||||
type: 'error-recoverable'
|
||||
key: string
|
||||
code: ConvoItemError
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export type ConvoState =
|
||||
| {
|
||||
status: ConvoStatus.Uninitialized
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Initializing
|
||||
items: []
|
||||
convo: undefined
|
||||
error: undefined
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Ready
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => void
|
||||
fetchMessageHistory: () => void
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Suspended
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Backgrounded
|
||||
items: ConvoItem[]
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
error: undefined
|
||||
sender: AppBskyActorDefs.ProfileViewBasic
|
||||
recipients: AppBskyActorDefs.ProfileViewBasic[]
|
||||
isFetchingHistory: boolean
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
sendMessage: (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
) => Promise<void>
|
||||
fetchMessageHistory: () => Promise<void>
|
||||
}
|
||||
| {
|
||||
status: ConvoStatus.Error
|
||||
items: []
|
||||
convo: undefined
|
||||
error: any
|
||||
sender: undefined
|
||||
recipients: undefined
|
||||
isFetchingHistory: false
|
||||
deleteMessage: undefined
|
||||
sendMessage: undefined
|
||||
fetchMessageHistory: undefined
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
|
||||
const CurrentConvoIdContext = React.createContext<{
|
||||
currentConvoId: string | undefined
|
||||
setCurrentConvoId: (convoId: string | undefined) => void
|
||||
}>({
|
||||
currentConvoId: undefined,
|
||||
setCurrentConvoId: () => {},
|
||||
})
|
||||
|
||||
export function useCurrentConvoId() {
|
||||
const ctx = React.useContext(CurrentConvoIdContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useCurrentConvoId must be used within a CurrentConvoIdProvider',
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function CurrentConvoIdProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [currentConvoId, setCurrentConvoId] = React.useState<
|
||||
string | undefined
|
||||
>()
|
||||
const ctx = React.useMemo(
|
||||
() => ({currentConvoId, setCurrentConvoId}),
|
||||
[currentConvoId],
|
||||
)
|
||||
return (
|
||||
<CurrentConvoIdContext.Provider value={ctx}>
|
||||
{children}
|
||||
</CurrentConvoIdContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {DEFAULT_POLL_INTERVAL} from '#/state/messages/events/const'
|
||||
import {
|
||||
MessagesEventBusDispatch,
|
||||
MessagesEventBusDispatchEvent,
|
||||
MessagesEventBusError,
|
||||
MessagesEventBusErrorCode,
|
||||
MessagesEventBusEvents,
|
||||
MessagesEventBusParams,
|
||||
MessagesEventBusStatus,
|
||||
} from '#/state/messages/events/types'
|
||||
|
||||
const LOGGER_CONTEXT = 'MessagesEventBus'
|
||||
|
||||
export class MessagesEventBus {
|
||||
private id: string
|
||||
|
||||
private agent: BskyAgent
|
||||
private __tempFromUserDid: string
|
||||
private emitter = new EventEmitter<MessagesEventBusEvents>()
|
||||
|
||||
private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing
|
||||
private error: MessagesEventBusError | undefined
|
||||
private latestRev: string | undefined = undefined
|
||||
private pollInterval = DEFAULT_POLL_INTERVAL
|
||||
private requestedPollIntervals: Map<string, number> = new Map()
|
||||
|
||||
constructor(params: MessagesEventBusParams) {
|
||||
this.id = nanoid(3)
|
||||
this.agent = params.agent
|
||||
this.__tempFromUserDid = params.__tempFromUserDid
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
requestPollInterval(interval: number) {
|
||||
const id = nanoid()
|
||||
this.requestedPollIntervals.set(id, interval)
|
||||
this.dispatch({
|
||||
event: MessagesEventBusDispatchEvent.UpdatePoll,
|
||||
})
|
||||
return () => {
|
||||
this.requestedPollIntervals.delete(id)
|
||||
this.dispatch({
|
||||
event: MessagesEventBusDispatchEvent.UpdatePoll,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
getLatestRev() {
|
||||
return this.latestRev
|
||||
}
|
||||
|
||||
onConnect(handler: () => void) {
|
||||
this.emitter.on('connect', handler)
|
||||
|
||||
if (
|
||||
this.status === MessagesEventBusStatus.Ready ||
|
||||
this.status === MessagesEventBusStatus.Backgrounded ||
|
||||
this.status === MessagesEventBusStatus.Suspended
|
||||
) {
|
||||
handler()
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.emitter.off('connect', handler)
|
||||
}
|
||||
}
|
||||
|
||||
onError(handler: (payload?: MessagesEventBusError) => void) {
|
||||
this.emitter.on('error', handler)
|
||||
|
||||
if (this.status === MessagesEventBusStatus.Error) {
|
||||
handler(this.error)
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.emitter.off('error', handler)
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
private dispatch(action: MessagesEventBusDispatch) {
|
||||
const prevStatus = this.status
|
||||
|
||||
switch (this.status) {
|
||||
case MessagesEventBusStatus.Initializing: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Ready: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.resetPoll()
|
||||
this.emitter.emit('connect')
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.resetPoll()
|
||||
this.emitter.emit('connect')
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.emitter.emit('error', action.payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Ready: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
this.emitter.emit('error', action.payload)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.UpdatePoll: {
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Backgrounded: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Suspend: {
|
||||
this.status = MessagesEventBusStatus.Suspended
|
||||
this.stopPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
this.emitter.emit('error', action.payload)
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.UpdatePoll: {
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Suspended: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume: {
|
||||
this.status = MessagesEventBusStatus.Ready
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Background: {
|
||||
this.status = MessagesEventBusStatus.Backgrounded
|
||||
this.resetPoll()
|
||||
break
|
||||
}
|
||||
case MessagesEventBusDispatchEvent.Error: {
|
||||
this.status = MessagesEventBusStatus.Error
|
||||
this.error = action.payload
|
||||
this.stopPoll()
|
||||
this.emitter.emit('error', action.payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case MessagesEventBusStatus.Error: {
|
||||
switch (action.event) {
|
||||
case MessagesEventBusDispatchEvent.Resume: {
|
||||
// basically reset
|
||||
this.status = MessagesEventBusStatus.Initializing
|
||||
this.error = undefined
|
||||
this.latestRev = undefined
|
||||
this.init()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${LOGGER_CONTEXT}: dispatch '${action.event}'`,
|
||||
{
|
||||
id: this.id,
|
||||
prev: prevStatus,
|
||||
next: this.status,
|
||||
},
|
||||
logger.DebugContext.convo,
|
||||
)
|
||||
}
|
||||
|
||||
private async init() {
|
||||
logger.debug(`${LOGGER_CONTEXT}: init`, {}, logger.DebugContext.convo)
|
||||
|
||||
try {
|
||||
const response = await this.agent.api.chat.bsky.convo.listConvos(
|
||||
{
|
||||
limit: 1,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
// throw new Error('UNCOMMENT TO TEST INIT FAILURE')
|
||||
|
||||
const {convos} = response.data
|
||||
|
||||
for (const convo of convos) {
|
||||
if (convo.rev > (this.latestRev = this.latestRev || convo.rev)) {
|
||||
this.latestRev = convo.rev
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Ready})
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
context: `${LOGGER_CONTEXT}: init failed`,
|
||||
})
|
||||
|
||||
this.dispatch({
|
||||
event: MessagesEventBusDispatchEvent.Error,
|
||||
payload: {
|
||||
exception: e,
|
||||
code: MessagesEventBusErrorCode.InitFailed,
|
||||
retry: () => {
|
||||
this.dispatch({event: MessagesEventBusDispatchEvent.Resume})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Polling
|
||||
*/
|
||||
|
||||
private isPolling = false
|
||||
private pollIntervalRef: NodeJS.Timeout | undefined
|
||||
|
||||
private getPollInterval() {
|
||||
switch (this.status) {
|
||||
case MessagesEventBusStatus.Ready: {
|
||||
const requested = Array.from(this.requestedPollIntervals.values())
|
||||
const lowest = Math.min(DEFAULT_POLL_INTERVAL, ...requested)
|
||||
return lowest
|
||||
}
|
||||
default:
|
||||
return DEFAULT_POLL_INTERVAL
|
||||
}
|
||||
}
|
||||
|
||||
private resetPoll() {
|
||||
this.pollInterval = this.getPollInterval()
|
||||
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`,
|
||||
// {
|
||||
// requestedPollIntervals: Array.from(
|
||||
// this.requestedPollIntervals.values(),
|
||||
// ),
|
||||
// },
|
||||
// logger.DebugContext.convo,
|
||||
// )
|
||||
|
||||
try {
|
||||
const response = await this.agent.api.chat.bsky.convo.getLog(
|
||||
{
|
||||
cursor: this.latestRev,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.__tempFromUserDid,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
|
||||
|
||||
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.dispatch({event: MessagesEventBusDispatchEvent.Resume})
|
||||
},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
this.isPolling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_POLL_INTERVAL = 20e3
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
const MessagesEventBusContext = React.createContext<MessagesEventBus | 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 Temp_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!,
|
||||
}),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isWeb && IS_DEV) {
|
||||
// @ts-ignore
|
||||
window.bus = bus
|
||||
}
|
||||
}, [bus])
|
||||
|
||||
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={bus}>
|
||||
{children}
|
||||
</MessagesEventBusContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessagesEventBusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const gate = useGate()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
if (gate('dms') && serviceUrl) {
|
||||
return (
|
||||
<Temp_MessagesEventBusProvider>{children}</Temp_MessagesEventBusProvider>
|
||||
)
|
||||
}
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
|
||||
|
||||
export type MessagesEventBusParams = {
|
||||
agent: BskyAgent
|
||||
__tempFromUserDid: string
|
||||
}
|
||||
|
||||
export enum MessagesEventBusStatus {
|
||||
Initializing = 'initializing',
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Backgrounded = 'backgrounded',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum MessagesEventBusDispatchEvent {
|
||||
Ready = 'ready',
|
||||
Error = 'error',
|
||||
Background = 'background',
|
||||
Suspend = 'suspend',
|
||||
Resume = 'resume',
|
||||
UpdatePoll = 'updatePoll',
|
||||
}
|
||||
|
||||
export enum MessagesEventBusErrorCode {
|
||||
Unknown = 'unknown',
|
||||
InitFailed = 'initFailed',
|
||||
PollFailed = 'pollFailed',
|
||||
}
|
||||
|
||||
export type MessagesEventBusError = {
|
||||
code: MessagesEventBusErrorCode
|
||||
exception?: Error
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export type MessagesEventBusDispatch =
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Ready
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Background
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Suspend
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Resume
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.Error
|
||||
payload: MessagesEventBusError
|
||||
}
|
||||
| {
|
||||
event: MessagesEventBusDispatchEvent.UpdatePoll
|
||||
}
|
||||
|
||||
export type TrailHandler = (
|
||||
events: ChatBskyConvoGetLog.OutputSchema['logs'],
|
||||
) => void
|
||||
|
||||
export type RequestPollIntervalHandler = (interval: number) => () => void
|
||||
export type OnConnectHandler = (handler: () => void) => () => void
|
||||
export type OnDisconnectHandler = (
|
||||
handler: (error?: MessagesEventBusError) => void,
|
||||
) => () => void
|
||||
|
||||
export type MessagesEventBusEvents = {
|
||||
events: [ChatBskyConvoGetLog.OutputSchema['logs']]
|
||||
connect: undefined
|
||||
error: [MessagesEventBusError] | undefined
|
||||
}
|
||||
@@ -1,48 +1,12 @@
|
||||
import React, {useContext, useState, useSyncExternalStore} from 'react'
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import React from 'react'
|
||||
|
||||
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id'
|
||||
import {MessagesEventBusProvider} from '#/state/messages/events'
|
||||
|
||||
const ChatContext = React.createContext<ConvoState | null>(null)
|
||||
|
||||
export function useChat() {
|
||||
const ctx = useContext(ChatContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useChat must be used within a ChatProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
convoId,
|
||||
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const {getAgent} = useAgent()
|
||||
const [convo] = useState(
|
||||
() =>
|
||||
new Convo({
|
||||
convoId,
|
||||
agent: new BskyAgent({
|
||||
service: serviceUrl,
|
||||
}),
|
||||
__tempFromUserDid: getAgent().session?.did!,
|
||||
}),
|
||||
export function MessagesProvider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<CurrentConvoIdProvider>
|
||||
<MessagesEventBusProvider>{children}</MessagesEventBusProvider>
|
||||
</CurrentConvoIdProvider>
|
||||
)
|
||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
convo.resume()
|
||||
|
||||
return () => {
|
||||
convo.background()
|
||||
}
|
||||
}, [convo]),
|
||||
)
|
||||
|
||||
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,7 +1,9 @@
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useOnMarkAsRead} from '#/state/queries/messages/list-converations'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {RQKEY as LIST_CONVOS_KEY} from './list-converations'
|
||||
import {useHeaders} from './temp-headers'
|
||||
|
||||
const RQKEY_ROOT = 'convo'
|
||||
@@ -23,3 +25,38 @@ export function useConvoQuery(convoId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarkAsReadMutation() {
|
||||
const headers = useHeaders()
|
||||
const {serviceUrl} = useDmServiceUrlStorage()
|
||||
const optimisticUpdate = useOnMarkAsRead()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
convoId,
|
||||
messageId,
|
||||
}: {
|
||||
convoId: string
|
||||
messageId?: string
|
||||
}) => {
|
||||
const agent = new BskyAgent({service: serviceUrl})
|
||||
await agent.api.chat.bsky.convo.updateRead(
|
||||
{
|
||||
convoId,
|
||||
messageId,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers,
|
||||
},
|
||||
)
|
||||
},
|
||||
onMutate({convoId}) {
|
||||
optimisticUpdate(convoId)
|
||||
},
|
||||
onSettled() {
|
||||
queryClient.invalidateQueries({queryKey: LIST_CONVOS_KEY})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import {BskyAgent} from '@atproto-labs/api'
|
||||
import {useInfiniteQuery} from '@tanstack/react-query'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {
|
||||
BskyAgent,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyConvoListConvos,
|
||||
} from '@atproto-labs/api'
|
||||
import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
|
||||
import {useHeaders} from './temp-headers'
|
||||
|
||||
@@ -27,3 +33,116 @@ export function useListConvos({refetchInterval}: {refetchInterval: number}) {
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnreadMessageCount() {
|
||||
const {currentConvoId} = useCurrentConvoId()
|
||||
const convos = useListConvos({
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const count =
|
||||
convos.data?.pages
|
||||
.flatMap(page => page.convos)
|
||||
.filter(convo => convo.id !== currentConvoId)
|
||||
.reduce((acc, convo) => {
|
||||
return acc + (!convo.muted && convo.unreadCount > 0 ? 1 : 0)
|
||||
}, 0) ?? 0
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
count,
|
||||
numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined,
|
||||
}
|
||||
}, [count])
|
||||
}
|
||||
|
||||
type ConvoListQueryData = {
|
||||
pageParams: Array<string | undefined>
|
||||
pages: Array<ChatBskyConvoListConvos.OutputSchema>
|
||||
}
|
||||
|
||||
export function useOnDeleteMessage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback(
|
||||
(chatId: string, messageId: string) => {
|
||||
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
|
||||
return optimisticUpdate(chatId, old, convo =>
|
||||
messageId === convo.lastMessage?.id
|
||||
? {
|
||||
...convo,
|
||||
lastMessage: {
|
||||
$type: 'chat.bsky.convo.defs#deletedMessageView',
|
||||
id: messageId,
|
||||
rev: '',
|
||||
},
|
||||
}
|
||||
: convo,
|
||||
)
|
||||
})
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
}
|
||||
|
||||
export function useOnNewMessage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback(
|
||||
(chatId: string, message: ChatBskyConvoDefs.MessageView) => {
|
||||
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
|
||||
return optimisticUpdate(chatId, old, convo => ({
|
||||
...convo,
|
||||
lastMessage: message,
|
||||
unreadCount: convo.unreadCount + 1,
|
||||
}))
|
||||
})
|
||||
queryClient.invalidateQueries({queryKey: RQKEY})
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
}
|
||||
|
||||
export function useOnCreateConvo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY})
|
||||
}, [queryClient])
|
||||
}
|
||||
|
||||
export function useOnMarkAsRead() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback(
|
||||
(chatId: string) => {
|
||||
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
|
||||
return optimisticUpdate(chatId, old, convo => ({
|
||||
...convo,
|
||||
unreadCount: 0,
|
||||
}))
|
||||
})
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
}
|
||||
|
||||
function optimisticUpdate(
|
||||
chatId: string,
|
||||
old: ConvoListQueryData,
|
||||
updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
|
||||
) {
|
||||
if (!old) {
|
||||
return old
|
||||
}
|
||||
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map(page => ({
|
||||
...page,
|
||||
convos: page.convos.map(convo =>
|
||||
chatId === convo.id ? updateFn(convo) : convo,
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {AtpSessionEvent} from '@atproto-labs/api'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {SessionAccount} from './types'
|
||||
import {isSessionDeactivated, isSessionExpired} from './util'
|
||||
import {IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
return new BskyAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
}
|
||||
|
||||
export async function createAgentAndResume(
|
||||
storedAccount: SessionAccount,
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service: storedAccount.service})
|
||||
if (storedAccount.pdsUrl) {
|
||||
agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl)
|
||||
}
|
||||
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
||||
const prevSession = {
|
||||
accessJwt: storedAccount.accessJwt ?? '',
|
||||
refreshJwt: storedAccount.refreshJwt ?? '',
|
||||
did: storedAccount.did,
|
||||
handle: storedAccount.handle,
|
||||
}
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
} else {
|
||||
agent.session = prevSession
|
||||
if (!storedAccount.deactivated) {
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
}
|
||||
}
|
||||
|
||||
return prepareAgent(agent, gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
{
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.login({identifier, password, authFactorToken})
|
||||
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
return prepareAgent(agent, moderation, gates, onSessionChange)
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
birthDate,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.createAccount({
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
})
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
if (!account.deactivated) {
|
||||
/*dont await*/ agent.upsertProfile(_existing => {
|
||||
return {
|
||||
displayName: '',
|
||||
// HACKFIX
|
||||
// creating a bunch of identical profile objects is breaking the relay
|
||||
// tossing this unspecced field onto it to reduce the size of the problem
|
||||
// -prf
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned)
|
||||
}
|
||||
|
||||
return prepareAgent(agent, gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
async function prepareAgent(
|
||||
agent: BskyAgent,
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
gates: Promise<void>,
|
||||
moderation: Promise<void>,
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all([gates, moderation])
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
agent.setPersistSessionHandler(event => {
|
||||
onSessionChange(agent, account.did, event)
|
||||
})
|
||||
return {agent, account}
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
||||
const account = agentToSessionAccount(agent)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
export function agentToSessionAccount(
|
||||
agent: BskyAgent,
|
||||
): SessionAccount | undefined {
|
||||
if (!agent.session) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
service: agent.service.toString(),
|
||||
did: agent.session.did,
|
||||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
}
|
||||
}
|
||||
+124
-452
@@ -1,11 +1,8 @@
|
||||
import React from 'react'
|
||||
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
|
||||
import {AtpSessionEvent, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
@@ -14,25 +11,15 @@ import {IS_DEV} from '#/env'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {
|
||||
agentToSessionAccount,
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
createAgentAndCreateAccount,
|
||||
createAgentAndLogin,
|
||||
isSessionDeactivated,
|
||||
isSessionExpired,
|
||||
} from './util'
|
||||
createAgentAndResume,
|
||||
} from './agent'
|
||||
import {getInitialState, reducer} from './reducer'
|
||||
|
||||
export {isSessionDeactivated} from './util'
|
||||
export type {SessionAccount} from '#/state/session/types'
|
||||
import {
|
||||
SessionAccount,
|
||||
SessionApiContext,
|
||||
SessionStateContext,
|
||||
} from '#/state/session/types'
|
||||
|
||||
export {isSessionDeactivated}
|
||||
|
||||
const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
configureModerationForGuest()
|
||||
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
|
||||
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
accounts: [],
|
||||
@@ -40,414 +27,125 @@ const StateContext = React.createContext<SessionStateContext>({
|
||||
hasSession: false,
|
||||
})
|
||||
|
||||
const AgentContext = React.createContext<BskyAgent | null>(null)
|
||||
|
||||
const ApiContext = React.createContext<SessionApiContext>({
|
||||
createAccount: async () => {},
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
initSession: async () => {},
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
updateCurrentAccount: () => {},
|
||||
clearCurrentAccount: () => {},
|
||||
})
|
||||
|
||||
let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
|
||||
|
||||
function __getAgent() {
|
||||
return __globalAgent
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: BskyAgent
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
type State = {
|
||||
accounts: SessionStateContext['accounts']
|
||||
currentAgentState: AgentState
|
||||
needsPersist: boolean
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [state, setState] = React.useState<State>(() => ({
|
||||
accounts: persisted.get('session').accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined, // assume logged out to start
|
||||
},
|
||||
needsPersist: false,
|
||||
}))
|
||||
|
||||
const clearCurrentAccount = React.useCallback(() => {
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
did: undefined,
|
||||
},
|
||||
needsPersist: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [state, dispatch] = React.useReducer(reducer, null, () =>
|
||||
getInitialState(persisted.get('session').accounts),
|
||||
)
|
||||
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(
|
||||
agent: BskyAgent,
|
||||
account: SessionAccount,
|
||||
event: AtpSessionEvent,
|
||||
session: AtpSessionData | undefined,
|
||||
) => {
|
||||
const expired = event === 'expired' || event === 'create-failed'
|
||||
|
||||
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`)
|
||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||
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,
|
||||
}
|
||||
dispatch({
|
||||
type: 'received-agent-event',
|
||||
agent,
|
||||
refreshedAccount,
|
||||
accountDid,
|
||||
sessionEvent,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
|
||||
async ({
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}) => {
|
||||
logger.info(`session: creating account`)
|
||||
async params => {
|
||||
const signal = cancelPendingTask()
|
||||
track('Try Create Account')
|
||||
logEvent('account:create:begin', {})
|
||||
const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
},
|
||||
const {agent, account} = await createAgentAndCreateAccount(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`session: created account`, {}, logger.DebugContext.session)
|
||||
track('Create Account')
|
||||
logEvent('account:create:success', {})
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const login = React.useCallback<SessionApiContext['login']>(
|
||||
async ({service, identifier, password, authFactorToken}, logContext) => {
|
||||
logger.debug(`session: login`, {}, logger.DebugContext.session)
|
||||
const {agent, account, fetchingGates} = await createAgentAndLogin({
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
async (params, logContext) => {
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndLogin(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
})
|
||||
|
||||
__globalAgent = agent
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
|
||||
|
||||
track('Sign In', {resumedSession: false})
|
||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logout = React.useCallback<SessionApiContext['logout']>(
|
||||
async logContext => {
|
||||
logger.debug(`session: logout`)
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.map(a => ({
|
||||
...a,
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
})),
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
logContext => {
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
type: 'logged-out',
|
||||
})
|
||||
logEvent('account:loggedOut', {logContext})
|
||||
},
|
||||
[setState],
|
||||
[cancelPendingTask],
|
||||
)
|
||||
|
||||
const initSession = React.useCallback<SessionApiContext['initSession']>(
|
||||
async account => {
|
||||
logger.debug(`session: initSession`, {}, logger.DebugContext.session)
|
||||
const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async storedAccount => {
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndResume(
|
||||
storedAccount,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
|
||||
const agent = new BskyAgent({service: account.service})
|
||||
|
||||
// restore the correct PDS URL if available
|
||||
if (account.pdsUrl) {
|
||||
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
agent.setPersistSessionHandler((event, session) => {
|
||||
onAgentSessionChange(agent, account, event, session)
|
||||
dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = agent
|
||||
await configureModerationForAccount(agent, account)
|
||||
|
||||
const accountOrSessionDeactivated =
|
||||
isSessionDeactivated(account.accessJwt) || account.deactivated
|
||||
|
||||
const prevSession = {
|
||||
accessJwt: account.accessJwt ?? '',
|
||||
refreshJwt: account.refreshJwt ?? '',
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
}
|
||||
|
||||
if (isSessionExpired(account)) {
|
||||
logger.debug(`session: attempting to resume using previous session`)
|
||||
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
freshAccount,
|
||||
...s.accounts.filter(a => a.did !== freshAccount.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: freshAccount.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.debug(`session: attempting to reuse previous session`)
|
||||
|
||||
agent.session = prevSession
|
||||
|
||||
__globalAgent = agent
|
||||
await fetchingGates
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: [
|
||||
account,
|
||||
...s.accounts.filter(a => a.did !== account.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: account.did,
|
||||
agent: agent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (accountOrSessionDeactivated) {
|
||||
// don't attempt to resume
|
||||
// use will be taken to the deactivated screen
|
||||
logger.debug(`session: reusing session for deactivated account`)
|
||||
return
|
||||
}
|
||||
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
resumeSessionWithFreshAccount()
|
||||
}
|
||||
|
||||
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
||||
logger.debug(`session: resumeSessionWithFreshAccount`)
|
||||
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
const sessionAccount = agentToSessionAccount(agent)
|
||||
/*
|
||||
* If `agent.resumeSession` fails above, it'll throw. This is just to
|
||||
* make TypeScript happy.
|
||||
*/
|
||||
if (!sessionAccount) {
|
||||
throw new Error(`session: initSession failed to establish a session`)
|
||||
}
|
||||
return sessionAccount
|
||||
}
|
||||
},
|
||||
[onAgentSessionChange],
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||
account => {
|
||||
setState(s => {
|
||||
return {
|
||||
accounts: s.accounts.filter(a => a.did !== account.did),
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
type: 'removed-account',
|
||||
accountDid: account.did,
|
||||
})
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
const updateCurrentAccount = React.useCallback<
|
||||
SessionApiContext['updateCurrentAccount']
|
||||
>(
|
||||
account => {
|
||||
setState(s => {
|
||||
const currentAccount = s.accounts.find(
|
||||
a => a.did === s.currentAgentState.did,
|
||||
)
|
||||
// ignore, should never happen
|
||||
if (!currentAccount) return s
|
||||
|
||||
const updatedAccount = {
|
||||
...currentAccount,
|
||||
handle: account.handle ?? currentAccount.handle,
|
||||
email: account.email ?? currentAccount.email,
|
||||
emailConfirmed:
|
||||
account.emailConfirmed ?? currentAccount.emailConfirmed,
|
||||
emailAuthFactor:
|
||||
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: [
|
||||
updatedAccount,
|
||||
...s.accounts.filter(a => a.did !== currentAccount.did),
|
||||
],
|
||||
currentAgentState: s.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[setState],
|
||||
[cancelPendingTask],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -464,70 +162,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
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,
|
||||
const synced = persisted.get('session')
|
||||
dispatch({
|
||||
type: 'synced-accounts',
|
||||
syncedAccounts: synced.accounts,
|
||||
syncedCurrentDid: synced.currentAccount?.did,
|
||||
})
|
||||
const syncedAccount = synced.accounts.find(
|
||||
a => a.did === synced.currentAccount?.did,
|
||||
)
|
||||
|
||||
if (selectedAccount && selectedAccount.refreshJwt) {
|
||||
if (selectedAccount.did !== state.currentAgentState.did) {
|
||||
logger.debug(`session: persisted onUpdate, switching accounts`, {
|
||||
from: {
|
||||
did: state.currentAgentState.did,
|
||||
},
|
||||
to: {
|
||||
did: selectedAccount.did,
|
||||
},
|
||||
})
|
||||
|
||||
initSession(selectedAccount)
|
||||
if (syncedAccount && syncedAccount.refreshJwt) {
|
||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
||||
resumeSession(syncedAccount)
|
||||
} else {
|
||||
logger.debug(`session: persisted onUpdate, updating session`, {})
|
||||
|
||||
/*
|
||||
* Use updated session in this tab's agent. Do not call
|
||||
* upsertAccount, since that will only persist the session that's
|
||||
* already persisted, and we'll get a loop between tabs.
|
||||
*/
|
||||
// @ts-ignore we checked for `refreshJwt` above
|
||||
__globalAgent.session = selectedAccount
|
||||
// TODO: This needs a setState.
|
||||
state.currentAgentState.agent.session = syncedAccount
|
||||
}
|
||||
} else if (!selectedAccount && state.currentAgentState.did) {
|
||||
logger.debug(
|
||||
`session: persisted onUpdate, logging out`,
|
||||
{},
|
||||
logger.DebugContext.session,
|
||||
)
|
||||
|
||||
/*
|
||||
* No need to do a hard logout here. If we reach this, tokens for this
|
||||
* account have already been cleared either by an `expired` event
|
||||
* handled by `persistSession` (which nukes this accounts tokens only),
|
||||
* or by a `logout` call which nukes all accounts tokens)
|
||||
*/
|
||||
logger.warn(`session: clear current account`)
|
||||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
configureModerationForGuest()
|
||||
setState(s => ({
|
||||
accounts: s.accounts,
|
||||
currentAgentState: {
|
||||
did: undefined,
|
||||
agent: PUBLIC_BSKY_AGENT,
|
||||
},
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [state, setState, initSession])
|
||||
}, [state, resumeSession])
|
||||
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
@@ -545,29 +198,37 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
createAccount,
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
}),
|
||||
[
|
||||
createAccount,
|
||||
login,
|
||||
logout,
|
||||
initSession,
|
||||
removeAccount,
|
||||
updateCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
],
|
||||
[createAccount, login, logout, resumeSession, removeAccount],
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent
|
||||
|
||||
const agent = state.currentAgentState.agent as BskyAgent
|
||||
return (
|
||||
<StateContext.Provider value={stateContext}>
|
||||
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
<AgentContext.Provider value={agent}>
|
||||
<StateContext.Provider value={stateContext}>
|
||||
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
</AgentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useOneTaskAtATime() {
|
||||
const abortController = React.useRef<AbortController | null>(null)
|
||||
const cancelPendingTask = React.useCallback(() => {
|
||||
if (abortController.current) {
|
||||
abortController.current.abort()
|
||||
}
|
||||
abortController.current = new AbortController()
|
||||
return abortController.current.signal
|
||||
}, [])
|
||||
return cancelPendingTask
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
@@ -594,6 +255,17 @@ export function useRequireAuth() {
|
||||
)
|
||||
}
|
||||
|
||||
export function useAgent() {
|
||||
return React.useMemo(() => ({getAgent: __getAgent}), [])
|
||||
export function useAgent(): {getAgent: () => BskyAgent} {
|
||||
const agent = React.useContext(AgentContext)
|
||||
if (!agent) {
|
||||
throw Error('useAgent() must be below <SessionProvider>.')
|
||||
}
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
getAgent() {
|
||||
return agent
|
||||
},
|
||||
}),
|
||||
[agent],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {readLabelers} from './agent-config'
|
||||
import {SessionAccount} from './types'
|
||||
|
||||
export function configureModerationForGuest() {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
switchToBskyAppLabeler()
|
||||
}
|
||||
|
||||
export async function configureModerationForAccount(
|
||||
agent: BskyAgent,
|
||||
account: SessionAccount,
|
||||
) {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
switchToBskyAppLabeler()
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
await trySwitchToTestAppLabeler(agent)
|
||||
}
|
||||
|
||||
// The code below is actually relevant to production (and isn't global).
|
||||
const labelerDids = await readLabelers(account.did).catch(_ => {})
|
||||
if (labelerDids) {
|
||||
agent.configureLabelersHeader(
|
||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
}
|
||||
}
|
||||
|
||||
function switchToBskyAppLabeler() {
|
||||
BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
}
|
||||
|
||||
async function trySwitchToTestAppLabeler(agent: BskyAgent) {
|
||||
const did = (
|
||||
await agent
|
||||
.resolveHandle({handle: 'mod-authority.test'})
|
||||
.catch(_ => undefined)
|
||||
)?.data.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
BskyAgent.configure({appLabelers: [did]})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {AtpSessionEvent} from '@atproto/api'
|
||||
|
||||
import {createPublicAgent} from './agent'
|
||||
import {SessionAccount} from './types'
|
||||
|
||||
// A hack so that the reducer can't read anything from the agent.
|
||||
// From the reducer's point of view, it should be a completely opaque object.
|
||||
type OpaqueBskyAgent = {
|
||||
readonly service: URL
|
||||
readonly api: unknown
|
||||
readonly app: unknown
|
||||
readonly com: unknown
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: OpaqueBskyAgent
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
export type State = {
|
||||
readonly accounts: SessionAccount[]
|
||||
readonly currentAgentState: AgentState
|
||||
needsPersist: boolean // Mutated in an effect.
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'received-agent-event'
|
||||
agent: OpaqueBskyAgent
|
||||
accountDid: string
|
||||
refreshedAccount: SessionAccount | undefined
|
||||
sessionEvent: AtpSessionEvent
|
||||
}
|
||||
| {
|
||||
type: 'switched-to-account'
|
||||
newAgent: OpaqueBskyAgent
|
||||
newAccount: SessionAccount
|
||||
}
|
||||
| {
|
||||
type: 'removed-account'
|
||||
accountDid: string
|
||||
}
|
||||
| {
|
||||
type: 'logged-out'
|
||||
}
|
||||
| {
|
||||
type: 'synced-accounts'
|
||||
syncedAccounts: SessionAccount[]
|
||||
syncedCurrentDid: string | undefined
|
||||
}
|
||||
|
||||
function createPublicAgentState(): AgentState {
|
||||
return {
|
||||
agent: createPublicAgent(),
|
||||
did: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
||||
return {
|
||||
accounts: persistedAccounts,
|
||||
currentAgentState: createPublicAgentState(),
|
||||
needsPersist: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'received-agent-event': {
|
||||
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
||||
if (agent !== state.currentAgentState.agent) {
|
||||
// Only consider events from the active agent.
|
||||
return state
|
||||
}
|
||||
if (sessionEvent === 'network-error') {
|
||||
// Don't change stored accounts but kick to the choose account screen.
|
||||
return {
|
||||
accounts: state.accounts,
|
||||
currentAgentState: createPublicAgentState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
const existingAccount = state.accounts.find(a => a.did === accountDid)
|
||||
if (
|
||||
!existingAccount ||
|
||||
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
|
||||
) {
|
||||
// Fast path without a state update.
|
||||
return state
|
||||
}
|
||||
return {
|
||||
accounts: state.accounts.map(a => {
|
||||
if (a.did === accountDid) {
|
||||
if (refreshedAccount) {
|
||||
return refreshedAccount
|
||||
} else {
|
||||
return {
|
||||
...a,
|
||||
// If we didn't receive a refreshed account, clear out the tokens.
|
||||
accessJwt: undefined,
|
||||
refreshJwt: undefined,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return a
|
||||
}
|
||||
}),
|
||||
currentAgentState: refreshedAccount
|
||||
? state.currentAgentState
|
||||
: createPublicAgentState(), // Log out if expired.
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'switched-to-account': {
|
||||
const {newAccount, newAgent} = action
|
||||
return {
|
||||
accounts: [
|
||||
newAccount,
|
||||
...state.accounts.filter(a => a.did !== newAccount.did),
|
||||
],
|
||||
currentAgentState: {
|
||||
did: newAccount.did,
|
||||
agent: newAgent,
|
||||
},
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'removed-account': {
|
||||
const {accountDid} = action
|
||||
return {
|
||||
accounts: state.accounts.filter(a => a.did !== accountDid),
|
||||
currentAgentState:
|
||||
state.currentAgentState.did === accountDid
|
||||
? createPublicAgentState() // Log out if removing the current one.
|
||||
: state.currentAgentState,
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'logged-out': {
|
||||
return {
|
||||
accounts: state.accounts.map(a => ({
|
||||
...a,
|
||||
// Clear tokens for *every* account (this is a hard logout).
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
})),
|
||||
currentAgentState: createPublicAgentState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'synced-accounts': {
|
||||
const {syncedAccounts, syncedCurrentDid} = action
|
||||
return {
|
||||
accounts: syncedAccounts,
|
||||
currentAgentState:
|
||||
syncedCurrentDid === state.currentAgentState.did
|
||||
? state.currentAgentState
|
||||
: createPublicAgentState(), // Log out if different user.
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,14 @@ export type SessionStateContext = {
|
||||
currentAccount: SessionAccount | undefined
|
||||
hasSession: boolean
|
||||
}
|
||||
|
||||
export type SessionApiContext = {
|
||||
createAccount: (props: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
@@ -32,25 +34,7 @@ export type SessionApiContext = {
|
||||
* access tokens from all accounts, so that returning as any user will
|
||||
* require a full login.
|
||||
*/
|
||||
logout: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => Promise<void>
|
||||
/**
|
||||
* A partial logout. Clears the `currentAccount` from session, but DOES NOT
|
||||
* clear access tokens from accounts, allowing the user to return to their
|
||||
* other accounts without logging in.
|
||||
*
|
||||
* Used when adding a new account, deleting an account.
|
||||
*/
|
||||
clearCurrentAccount: () => void
|
||||
initSession: (account: SessionAccount) => Promise<void>
|
||||
logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void
|
||||
resumeSession: (account: SessionAccount) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
updateCurrentAccount: (
|
||||
account: Partial<
|
||||
Pick<
|
||||
SessionAccount,
|
||||
'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
|
||||
>
|
||||
>,
|
||||
) => void
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
|
||||
import {hasProp} from '#/lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {SessionAccount} from './types'
|
||||
|
||||
export function readLastActiveAccount() {
|
||||
const {currentAccount, accounts} = persisted.get('session')
|
||||
return accounts.find(a => a.did === currentAccount?.did)
|
||||
}
|
||||
|
||||
export function isSessionDeactivated(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isSessionExpired(account: SessionAccount) {
|
||||
try {
|
||||
if (account.accessJwt) {
|
||||
const decoded = jwtDecode(account.accessJwt)
|
||||
if (decoded.exp) {
|
||||
const didExpire = Date.now() >= decoded.exp * 1000
|
||||
return didExpire
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: could not decode jwt`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {hasProp} from '#/lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {readLabelers} from '../agent-config'
|
||||
import {SessionAccount, SessionApiContext} from '../types'
|
||||
|
||||
export function isSessionDeactivated(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function readLastActiveAccount() {
|
||||
const {currentAccount, accounts} = persisted.get('session')
|
||||
return accounts.find(a => a.did === currentAccount?.did)
|
||||
}
|
||||
|
||||
export function agentToSessionAccount(
|
||||
agent: BskyAgent,
|
||||
): SessionAccount | undefined {
|
||||
if (!agent.session) return undefined
|
||||
|
||||
return {
|
||||
service: agent.service.toString(),
|
||||
did: agent.session.did,
|
||||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function configureModerationForGuest() {
|
||||
switchToBskyAppLabeler()
|
||||
}
|
||||
|
||||
export async function configureModerationForAccount(
|
||||
agent: BskyAgent,
|
||||
account: SessionAccount,
|
||||
) {
|
||||
switchToBskyAppLabeler()
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
await trySwitchToTestAppLabeler(agent)
|
||||
}
|
||||
|
||||
const labelerDids = await readLabelers(account.did).catch(_ => {})
|
||||
if (labelerDids) {
|
||||
agent.configureLabelersHeader(
|
||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
}
|
||||
}
|
||||
|
||||
function switchToBskyAppLabeler() {
|
||||
BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
}
|
||||
|
||||
async function trySwitchToTestAppLabeler(agent: BskyAgent) {
|
||||
const did = (
|
||||
await agent
|
||||
.resolveHandle({handle: 'mod-authority.test'})
|
||||
.catch(_ => undefined)
|
||||
)?.data.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
BskyAgent.configure({appLabelers: [did]})
|
||||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(account: SessionAccount) {
|
||||
try {
|
||||
if (account.accessJwt) {
|
||||
const decoded = jwtDecode(account.accessJwt)
|
||||
if (decoded.exp) {
|
||||
const didExpire = Date.now() >= decoded.exp * 1000
|
||||
return didExpire
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: could not decode jwt`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin({
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
}) {
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.login({identifier, password, authFactorToken})
|
||||
|
||||
const account = agentToSessionAccount(agent)
|
||||
if (!agent.session || !account) {
|
||||
throw new Error(`session: login failed to establish a session`)
|
||||
}
|
||||
|
||||
const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
await configureModerationForAccount(agent, account)
|
||||
|
||||
return {
|
||||
agent,
|
||||
account,
|
||||
fetchingGates,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount({
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: Parameters<SessionApiContext['createAccount']>[0]) {
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.createAccount({
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
})
|
||||
|
||||
const account = agentToSessionAccount(agent)!
|
||||
if (!agent.session || !account) {
|
||||
throw new Error(`session: createAccount failed to establish a session`)
|
||||
}
|
||||
|
||||
const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
|
||||
if (!account.deactivated) {
|
||||
/*dont await*/ agent.upsertProfile(_existing => {
|
||||
return {
|
||||
displayName: '',
|
||||
|
||||
// HACKFIX
|
||||
// creating a bunch of identical profile objects is breaking the relay
|
||||
// tossing this unspecced field onto it to reduce the size of the problem
|
||||
// -prf
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await configureModerationForAccount(agent, account)
|
||||
|
||||
return {
|
||||
agent,
|
||||
account,
|
||||
fetchingGates,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -211,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
|
||||
}
|
||||
|
||||
@@ -298,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`)
|
||||
@@ -328,7 +344,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
image: gif.media_formats.preview.url,
|
||||
likelyType: LikelyType.HTML,
|
||||
title: gif.content_description,
|
||||
description: '',
|
||||
description: createGIFDescription(gif.content_description),
|
||||
},
|
||||
})
|
||||
setExtGif(gif)
|
||||
@@ -343,11 +359,11 @@ export const ComposePost = observer(function ComposePost({
|
||||
? {
|
||||
...ext,
|
||||
meta: {
|
||||
...ext?.meta,
|
||||
description:
|
||||
altText.trim().length === 0
|
||||
? ''
|
||||
: `Alt text: ${altText.trim()}`,
|
||||
...ext.meta,
|
||||
description: createGIFDescription(
|
||||
ext.meta.title ?? '',
|
||||
altText,
|
||||
),
|
||||
},
|
||||
}
|
||||
: ext,
|
||||
@@ -433,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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -59,6 +60,7 @@ export function GifAltText({
|
||||
|
||||
if (!gif || !params) return null
|
||||
|
||||
const parsedAlt = parseAltFromGIFDescription(link.description)
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
@@ -80,7 +82,7 @@ export function GifAltText({
|
||||
a.align_center,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.75)'},
|
||||
]}>
|
||||
{link.description ? (
|
||||
{parsedAlt.isPreferred ? (
|
||||
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
|
||||
) : (
|
||||
<Plus size="sm" fill={t.palette.white} />
|
||||
@@ -102,7 +104,7 @@ export function GifAltText({
|
||||
onSubmit={onPressSubmit}
|
||||
link={link}
|
||||
params={params}
|
||||
initalValue={link.description.replace('Alt text: ', '')}
|
||||
initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''}
|
||||
key={link.uri}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
@@ -114,15 +116,16 @@ function AltTextInner({
|
||||
onSubmit,
|
||||
link,
|
||||
params,
|
||||
initalValue,
|
||||
initialValue: initalValue,
|
||||
}: {
|
||||
onSubmit: (text: string) => void
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
initalValue: string
|
||||
initialValue: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [altText, setAltText] = useState(initalValue)
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const onPressSubmit = useCallback(() => {
|
||||
onSubmit(altText)
|
||||
@@ -147,6 +150,11 @@ function AltTextInner({
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
autoFocus
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
@@ -51,6 +52,7 @@ export function FeedPage({
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen, track} = useAnalytics()
|
||||
const headerOffset = useHeaderOffset()
|
||||
const feedFeedback = useFeedFeedback(feed, hasSession)
|
||||
const scrollElRef = React.useRef<ListMethods>(null)
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
const gate = useGate()
|
||||
@@ -113,20 +115,22 @@ export function FeedPage({
|
||||
return (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
<MainScrollProvider>
|
||||
<Feed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
enabled={isPageFocused}
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
pollInterval={POLL_FREQ}
|
||||
disablePoll={hasNew}
|
||||
scrollElRef={scrollElRef}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
onHasNew={setHasNew}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
<FeedFeedbackProvider value={feedFeedback}>
|
||||
<Feed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
enabled={isPageFocused}
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
pollInterval={POLL_FREQ}
|
||||
disablePoll={hasNew}
|
||||
scrollElRef={scrollElRef}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
onHasNew={setHasNew}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
</MainScrollProvider>
|
||||
{(isScrolledDown || adjustedHasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -6,12 +6,11 @@ import {RichText} from '#/components/RichText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {Trans, msg, Plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
usePinFeedMutation,
|
||||
@@ -265,10 +264,11 @@ export function FeedSourceCardLoaded({
|
||||
|
||||
{showLikes && feed.type === 'feed' ? (
|
||||
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
|
||||
<Trans>
|
||||
Liked by {feed.likeCount || 0}{' '}
|
||||
{pluralize(feed.likeCount || 0, 'user')}
|
||||
</Trans>
|
||||
<Plural
|
||||
value={feed.likeCount || 0}
|
||||
one="Liked by # user"
|
||||
other="Liked by # users"
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
|
||||
@@ -78,7 +78,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
|
||||
|
||||
try {
|
||||
await saveImageToMediaLibrary({uri})
|
||||
Toast.show(_(msg`Saved to your camera roll.`))
|
||||
Toast.show(_(msg`Saved to your camera roll`))
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`Failed to save image: ${String(e)}`))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
@@ -28,7 +28,6 @@ export function Component() {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
const {getAgent} = useAgent()
|
||||
const {updateCurrentAccount} = useSessionApi()
|
||||
const {_} = useLingui()
|
||||
const [stage, setStage] = useState<Stages>(Stages.InputEmail)
|
||||
const [email, setEmail] = useState<string>(currentAccount?.email || '')
|
||||
@@ -51,10 +50,7 @@ export function Component() {
|
||||
setStage(Stages.ConfirmCode)
|
||||
} else {
|
||||
await getAgent().com.atproto.server.updateEmail({email: email.trim()})
|
||||
updateCurrentAccount({
|
||||
email: email.trim(),
|
||||
emailConfirmed: false,
|
||||
})
|
||||
await getAgent().resumeSession(getAgent().session!)
|
||||
Toast.show(_(msg`Email updated`))
|
||||
setStage(Stages.Done)
|
||||
}
|
||||
@@ -83,10 +79,7 @@ export function Component() {
|
||||
email: email.trim(),
|
||||
token: confirmationCode.trim(),
|
||||
})
|
||||
updateCurrentAccount({
|
||||
email: email.trim(),
|
||||
emailConfirmed: false,
|
||||
})
|
||||
await getAgent().resumeSession(getAgent().session!)
|
||||
Toast.show(_(msg`Email updated`))
|
||||
setStage(Stages.Done)
|
||||
} catch (e) {
|
||||
|
||||
@@ -15,12 +15,7 @@ import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
|
||||
import {useServiceQuery} from '#/state/queries/service'
|
||||
import {
|
||||
SessionAccount,
|
||||
useAgent,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
@@ -73,10 +68,10 @@ export function Inner({
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const {updateCurrentAccount} = useSessionApi()
|
||||
const {closeModal} = useModalControls()
|
||||
const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} =
|
||||
useUpdateHandleMutation()
|
||||
const {getAgent} = useAgent()
|
||||
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
@@ -116,9 +111,7 @@ export function Inner({
|
||||
await updateHandle({
|
||||
handle: newHandle,
|
||||
})
|
||||
updateCurrentAccount({
|
||||
handle: newHandle,
|
||||
})
|
||||
await getAgent().resumeSession(getAgent().session!)
|
||||
closeModal()
|
||||
onChanged()
|
||||
} catch (err: any) {
|
||||
@@ -134,9 +127,9 @@ export function Inner({
|
||||
onChanged,
|
||||
track,
|
||||
closeModal,
|
||||
updateCurrentAccount,
|
||||
updateHandle,
|
||||
serviceInfo,
|
||||
getAgent,
|
||||
])
|
||||
|
||||
// rendering
|
||||
@@ -507,7 +500,9 @@ function CustomHandleForm({
|
||||
<Text type="xl-medium" style={[s.white, s.textCenter]}>
|
||||
{canSave
|
||||
? _(msg`Update to ${handle}`)
|
||||
: _(msg`Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`)}
|
||||
: isDNSForm
|
||||
? _(msg`Verify DNS Record`)
|
||||
: _(msg`Verify Text File`)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Component({}: {}) {
|
||||
const theme = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {getAgent} = useAgent()
|
||||
const {clearCurrentAccount, removeAccount} = useSessionApi()
|
||||
const {removeAccount} = useSessionApi()
|
||||
const {_} = useLingui()
|
||||
const {closeModal} = useModalControls()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -69,7 +69,6 @@ export function Component({}: {}) {
|
||||
Toast.show(_(msg`Your account has been deleted`))
|
||||
resetToTab('HomeTab')
|
||||
removeAccount(currentAccount)
|
||||
clearCurrentAccount()
|
||||
closeModal()
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
@@ -84,26 +83,26 @@ export function Component({}: {}) {
|
||||
<ScrollView style={[pal.view]} keyboardShouldPersistTaps="handled">
|
||||
<View style={[styles.titleContainer, pal.view]}>
|
||||
<Text type="title-xl" style={[s.textCenter, pal.text]}>
|
||||
<Trans>Delete Account</Trans>
|
||||
<Trans>
|
||||
Delete Account{' '}
|
||||
<Text type="title-xl" style={[pal.text, s.bold]}>
|
||||
"
|
||||
</Text>
|
||||
<Text
|
||||
type="title-xl"
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
isMobile ? styles.titleMobile : styles.titleDesktop,
|
||||
pal.text,
|
||||
s.bold,
|
||||
]}>
|
||||
{currentAccount?.handle}
|
||||
</Text>
|
||||
<Text type="title-xl" style={[pal.text, s.bold]}>
|
||||
"
|
||||
</Text>
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[pal.view, s.flexRow]}>
|
||||
<Text type="title-xl" style={[pal.text, s.bold]}>
|
||||
{' "'}
|
||||
</Text>
|
||||
<Text
|
||||
type="title-xl"
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
isMobile ? styles.titleMobile : styles.titleDesktop,
|
||||
pal.text,
|
||||
s.bold,
|
||||
]}>
|
||||
{currentAccount?.handle}
|
||||
</Text>
|
||||
<Text type="title-xl" style={[pal.text, s.bold]}>
|
||||
{'"'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!isEmailSent ? (
|
||||
<>
|
||||
|
||||
@@ -131,10 +131,10 @@ export function Component({
|
||||
) : (
|
||||
<View>
|
||||
<Text style={[pal.textLight]}>
|
||||
<Text type="md-bold" style={[pal.textLight, s.mr5]}>
|
||||
<Trans>Not Applicable.</Trans>
|
||||
</Text>
|
||||
<Trans>
|
||||
<Text type="md-bold" style={[pal.textLight]}>
|
||||
Not Applicable.
|
||||
</Text>{' '}
|
||||
This warning is only available for posts with media attached.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user