Merge main into native-video-compress

Drops the parallel composer:video:probe* events added in 127d0288 in favor
of main's video:upload:* funnel from #10991. Reshapes the new module's
compressVideo to set passthroughReason on every skip branch so it conforms
to the VideoCompressSkipReason contract, including a new 'below-thresholds'
reason for the shouldCompress passthrough. Probe data wiring into the
funnel comes in a follow-up commit.
This commit is contained in:
vineyardbovines
2026-06-25 18:32:47 -04:00
37 changed files with 1477 additions and 617 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \ RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \ nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \ nvm use $NODE_VERSION && \
npm install --global pnpm@11.7.0 && \ npm install --global pnpm@11.9.0 && \
pnpm install --frozen-lockfile && \ pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \ cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \ pnpm intl:build && \
-16
View File
@@ -946,14 +946,6 @@
"count": 1 "count": 1
} }
}, },
"src/lib/hooks/useNotificationHandler.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 16
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": { "src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -2183,14 +2175,6 @@
"count": 2 "count": 2
} }
}, },
"src/view/com/modals/Modal.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": { "src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
+2 -2
View File
@@ -8,7 +8,7 @@
"devEngines": { "devEngines": {
"packageManager": { "packageManager": {
"name": "pnpm", "name": "pnpm",
"version": "11.7.0", "version": "11.9.0",
"onFail": "warn" "onFail": "warn"
}, },
"runtime": { "runtime": {
@@ -94,7 +94,7 @@
"update-actions": "pnpm dlx actions-up --min-age 7" "update-actions": "pnpm dlx actions-up --min-age 7"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.15", "@atproto/api": "0.20.20",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
+78 -69
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {} configDependencies: {}
packageManagerDependencies: packageManagerDependencies:
'@pnpm/exe': '@pnpm/exe':
specifier: 11.7.0 specifier: 11.9.0
version: 11.7.0 version: 11.9.0
pnpm: pnpm:
specifier: 11.7.0 specifier: 11.9.0
version: 11.7.0 version: 11.9.0
packages: packages:
'@pnpm/exe@11.7.0': '@pnpm/exe@11.9.0':
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==} resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
hasBin: true hasBin: true
'@pnpm/linux-arm64@11.7.0': '@pnpm/linux-arm64@11.9.0':
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==} resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@pnpm/linux-x64@11.7.0': '@pnpm/linux-x64@11.9.0':
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==} resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@pnpm/linuxstatic-arm64@11.7.0': '@pnpm/linuxstatic-arm64@11.9.0':
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==} resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/linuxstatic-x64@11.7.0': '@pnpm/linuxstatic-x64@11.9.0':
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==} resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/macos-arm64@11.7.0': '@pnpm/macos-arm64@11.9.0':
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==} resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@pnpm/win-arm64@11.7.0': '@pnpm/win-arm64@11.9.0':
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==} resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@pnpm/win-x64@11.7.0': '@pnpm/win-x64@11.9.0':
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==} resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
pnpm@11.7.0: pnpm@11.9.0:
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
engines: {node: '>=22.13'} engines: {node: '>=22.13'}
hasBin: true hasBin: true
snapshots: snapshots:
'@pnpm/exe@11.7.0': '@pnpm/exe@11.9.0':
dependencies: dependencies:
'@reflink/reflink': 0.1.19 '@reflink/reflink': 0.1.19
detect-libc: 2.1.2 detect-libc: 2.1.2
optionalDependencies: optionalDependencies:
'@pnpm/linux-arm64': 11.7.0 '@pnpm/linux-arm64': 11.9.0
'@pnpm/linux-x64': 11.7.0 '@pnpm/linux-x64': 11.9.0
'@pnpm/linuxstatic-arm64': 11.7.0 '@pnpm/linuxstatic-arm64': 11.9.0
'@pnpm/linuxstatic-x64': 11.7.0 '@pnpm/linuxstatic-x64': 11.9.0
'@pnpm/macos-arm64': 11.7.0 '@pnpm/macos-arm64': 11.9.0
'@pnpm/win-arm64': 11.7.0 '@pnpm/win-arm64': 11.9.0
'@pnpm/win-x64': 11.7.0 '@pnpm/win-x64': 11.9.0
'@pnpm/linux-arm64@11.7.0': '@pnpm/linux-arm64@11.9.0':
optional: true optional: true
'@pnpm/linux-x64@11.7.0': '@pnpm/linux-x64@11.9.0':
optional: true optional: true
'@pnpm/linuxstatic-arm64@11.7.0': '@pnpm/linuxstatic-arm64@11.9.0':
optional: true optional: true
'@pnpm/linuxstatic-x64@11.7.0': '@pnpm/linuxstatic-x64@11.9.0':
optional: true optional: true
'@pnpm/macos-arm64@11.7.0': '@pnpm/macos-arm64@11.9.0':
optional: true optional: true
'@pnpm/win-arm64@11.7.0': '@pnpm/win-arm64@11.9.0':
optional: true optional: true
'@pnpm/win-x64@11.7.0': '@pnpm/win-x64@11.9.0':
optional: true optional: true
'@reflink/reflink-darwin-arm64@0.1.19': '@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
pnpm@11.7.0: {} pnpm@11.9.0: {}
--- ---
lockfileVersion: '9.0' lockfileVersion: '9.0'
@@ -241,8 +241,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.15 specifier: 0.20.20
version: 0.20.15 version: 0.20.20
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@@ -876,32 +876,36 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.15': '@atproto/api@0.20.20':
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==} resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.1':
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==} resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-data@0.1.1': '@atproto/lex-data@0.1.2':
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==} resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-json@0.1.0': '@atproto/lex-json@0.1.1':
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==} resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lexicon@0.7.1': '@atproto/lexicon@0.7.2':
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==} resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.1': '@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/xrpc@0.8.0': '@atproto/syntax@0.6.2':
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==} resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.1':
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@babel/code-frame@7.10.4': '@babel/code-frame@7.10.4':
@@ -9472,40 +9476,40 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.15': '@atproto/api@0.20.20':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.1
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.2
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.2
'@atproto/xrpc': 0.8.0 '@atproto/xrpc': 0.8.1
await-lock: 3.0.0 await-lock: 3.0.0
multiformats: 13.4.2 multiformats: 13.4.2
tlds: 1.261.0 tlds: 1.261.0
zod: 3.25.76 zod: 3.25.76
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.1':
dependencies: dependencies:
'@atproto/lex-data': 0.1.1 '@atproto/lex-data': 0.1.2
'@atproto/lex-json': 0.1.0 '@atproto/lex-json': 0.1.1
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.2
zod: 3.25.76 zod: 3.25.76
'@atproto/lex-data@0.1.1': '@atproto/lex-data@0.1.2':
dependencies: dependencies:
multiformats: 13.4.2 multiformats: 13.4.2
tslib: 2.8.1 tslib: 2.8.1
uint8arrays: 5.1.1 uint8arrays: 5.1.1
unicode-segmenter: 0.14.5 unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.0': '@atproto/lex-json@0.1.1':
dependencies: dependencies:
'@atproto/lex-data': 0.1.1 '@atproto/lex-data': 0.1.2
tslib: 2.8.1 tslib: 2.8.1
'@atproto/lexicon@0.7.1': '@atproto/lexicon@0.7.2':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.1
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.2
multiformats: 13.4.2 multiformats: 13.4.2
zod: 3.25.76 zod: 3.25.76
@@ -9514,9 +9518,14 @@ snapshots:
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
tslib: 2.8.1 tslib: 2.8.1
'@atproto/xrpc@0.8.0': '@atproto/syntax@0.6.2':
dependencies: dependencies:
'@atproto/lexicon': 0.7.1 iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.1':
dependencies:
'@atproto/lexicon': 0.7.2
zod: 3.25.76 zod: 3.25.76
'@babel/code-frame@7.10.4': '@babel/code-frame@7.10.4':
+96 -17
View File
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native' import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types' import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -252,23 +253,6 @@ export type Events = {
'composer:image:edit': { 'composer:image:edit': {
platform: Platform['OS'] platform: Platform['OS']
} }
'composer:video:probe': {
mimeType: string
codec: string
width: number
height: number
duration: number
bitrate: number
fileSize: number
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
wouldCompress: boolean
}
'composer:video:probeFailed': {
safeMessage: string
}
'composerPrompt:press': {} 'composerPrompt:press': {}
'composerPrompt:camera:press': {} 'composerPrompt:camera:press': {}
'composerPrompt:gallery:press': {} 'composerPrompt:gallery:press': {}
@@ -1348,4 +1332,99 @@ export type Events = {
'invite:followersPromo:press': {} 'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner // user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {} 'invite:followersPromo:dismiss': {}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
// native:react-native-compressor@1.13.0). jobId is added once the server
// returns it. Sizes / codecs / dimensions / timings only - never content.
'video:upload:picked': {
uploadId: string
engine: string
sourceMimeType?: string
sourceBytes?: number
sourceDurationMs?: number
sourceWidth?: number
sourceHeight?: number
}
'video:upload:compressStarted': {
uploadId: string
engine: string
sourceBytes?: number
}
'video:upload:compressCompleted': {
uploadId: string
engine: string
bytesIn?: number
bytesOut: number
outputMimeType: string
elapsedMs: number
}
'video:upload:compressSkipped': {
uploadId: string
engine: string
skipReason: VideoCompressSkipReason
bytes: number
mimeType: string
elapsedMs: number
}
'video:upload:compressFailed': {
uploadId: string
engine: string
errorClass: string
elapsedMs: number
}
'video:upload:uploadStarted': {
uploadId: string
engine: string
bytes: number
}
'video:upload:uploadCompleted': {
uploadId: string
engine: string
jobId: string
bytes: number
elapsedMs: number
throughputBytesPerSec: number
}
'video:upload:uploadFailed': {
uploadId: string
engine: string
bytes: number
errorClass: string
elapsedMs: number
}
'video:upload:processingStarted': {
uploadId: string
engine: string
jobId: string
}
'video:upload:processingCompleted': {
uploadId: string
engine: string
jobId: string
elapsedMs: number
}
'video:upload:processingFailed': {
uploadId: string
engine: string
jobId: string
errorClass: string
elapsedMs: number
}
'video:upload:published': {
uploadId: string
engine: string
jobId: string
// wall-clock from picked to published
totalElapsedMs: number
}
// The event that measures the actual problem: users giving up mid-wait.
'video:upload:abandoned': {
uploadId: string
engine: string
phase: 'compress' | 'upload' | 'processing'
jobId?: string
elapsedInPhaseMs: number
}
} }
+93
View File
@@ -0,0 +1,93 @@
import {Pressable} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const positionStyles = {
'top-left': {
top: a.p_xs.padding,
left: a.p_xs.padding,
},
'top-right': {
top: a.p_xs.padding,
right: a.p_xs.padding,
},
'bottom-left': {
bottom: a.p_xs.padding,
left: a.p_xs.padding,
},
'bottom-right': {
bottom: a.p_xs.padding,
right: a.p_xs.padding,
},
}
export function AltBadgeWithDialog({
text,
position,
}: {
text: string
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}) {
const t = useTheme()
const {t: l} = useLingui()
const large = useLargeAltBadgeEnabled()
const control = Prompt.usePromptControl()
const pos = position ? [a.absolute, positionStyles[position]] : {}
return (
<>
<Pressable
testID="altBadgeWithDialogButton"
accessibilityRole="button"
accessibilityLabel={l`Show alt text`}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={s => [
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
large && {
padding: 6,
},
{
opacity: 0.8,
},
pos,
s.hovered || s.pressed
? [
{
opacity: 1,
},
]
: [],
]}>
<Text
accessible={false}
style={[a.font_bold, large ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</Pressable>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Cancel cta={l`Close`} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
+5 -3
View File
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages' import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {resetPostsFeedQueries} from '#/state/queries/post-feed' import {resetPostsFeedQueries} from '#/state/queries/post-feed'
import {atoms as a, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme, web} from '#/alf'
import * as Select from '#/components/Select' import * as Select from '#/components/Select'
import {Button} from './Button' import {Button, ButtonIcon} from './Button'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe'
export function AppLanguageDropdown() { export function AppLanguageDropdown() {
const t = useTheme() const t = useTheme()
@@ -57,6 +58,7 @@ export function AppLanguageDropdown() {
native: [a.gap_xs], native: [a.gap_xs],
}), }),
]}> ]}>
<ButtonIcon icon={EarthIcon} size="md" />
<Select.ValueText <Select.ValueText
placeholder={_(msg`Select an app language`)} placeholder={_(msg`Select an app language`)}
style={[t.atoms.text_contrast_medium]} style={[t.atoms.text_contrast_medium]}
@@ -68,7 +70,7 @@ export function AppLanguageDropdown() {
<Select.Content <Select.Content
label={_(msg`Select language`)} label={_(msg`Select language`)}
renderItem={({label, value}) => ( renderItem={({label, value}) => (
<Select.Item value={value} label={label}> <Select.Item value={value} label={label} style={web([a.pointer])}>
<Select.ItemIndicator /> <Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText> <Select.ItemText>{label}</Select.ItemText>
</Select.Item> </Select.Item>
@@ -66,19 +66,7 @@ export function GifEmbed({
a.overflow_hidden, a.overflow_hidden,
{backgroundColor: t.palette.black}, {backgroundColor: t.palette.black},
]}> ]}>
<View <View style={[a.absolute, a.inset_0]}>
style={[
a.absolute,
/*
* Aspect ratio was being clipped weirdly on web -esb
*/
{
top: -2,
bottom: -2,
left: -2,
right: -2,
},
]}>
<MediaInsetBorder /> <MediaInsetBorder />
<GifPresentationControls <GifPresentationControls
onPress={onPress} onPress={onPress}
@@ -1,18 +1,13 @@
import { import {ActivityIndicator, View} from 'react-native'
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill' import {Fill} from '#/components/Fill'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -29,6 +24,7 @@ export function GifPresentationControls({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const largeBadge = useLargeAltBadgeEnabled()
return ( return (
<> <>
@@ -64,74 +60,41 @@ export function GifPresentationControls({
]} ]}
/> />
)} )}
<View style={styles.gifBadgeContainer}> <View
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}> style={[
a.absolute,
a.flex_row,
a.z_10,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeBadge && {
gap: 4,
},
]}>
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
largeBadge && {
padding: 6,
},
{
opacity: 0.8,
},
]}>
<Text style={[a.font_bold, largeBadge ? a.text_xs : {fontSize: 8}]}>
<Trans>GIF</Trans> <Trans>GIF</Trans>
</Text> </Text>
</View> </View>
{altText && <AltBadge text={altText} />} {altText && <AltBadgeWithDialog text={altText} />}
</View>
</> </>
) )
} }
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
gifBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
left: 6,
bottom: 6,
zIndex: 2,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
},
})
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences' import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
@@ -98,6 +99,7 @@ export function VideoEmbedInnerNative({
altText={embed.alt} altText={embed.alt}
/> />
) : ( ) : (
<>
<VideoPresentationControls <VideoPresentationControls
enterFullscreen={() => { enterFullscreen={() => {
videoRef.current?.enterFullscreen(true) videoRef.current?.enterFullscreen(true)
@@ -111,6 +113,10 @@ export function VideoEmbedInnerNative({
isPlaying={isPlaying} isPlaying={isPlaying}
timeRemaining={timeRemaining} timeRemaining={timeRemaining}
/> />
{embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
</>
)} )}
<MediaInsetBorder /> <MediaInsetBorder />
<KeepAwake enabled={isPlaying} /> <KeepAwake enabled={isPlaying} />
@@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate' import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls' import {Controls} from './web-controls/VideoControls'
@@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({
const [hlsLoading, setHlsLoading] = useState(false) const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId() const figId = useId()
const {_} = useLingui() const {_} = useLingui()
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
// send error up to error boundary // send error up to error boundary
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
</figcaption> </figcaption>
)} )}
</figure> </figure>
{!isFullscreen && !isGif && embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
<Controls <Controls
videoRef={videoRef} videoRef={videoRef}
hlsRef={hlsRef} hlsRef={hlsRef}
@@ -88,7 +95,7 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen} onScreen={onScreen}
fullscreenRef={containerRef} fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack} hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'} isGif={isGif}
altText={embed.alt} altText={embed.alt}
updateCuePositions={updateCuePositions} updateCuePositions={updateCuePositions}
/> />
@@ -1,8 +1,12 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' import {
isChatPreferenceName,
type NotificationSettingsPreferenceName,
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls' import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
type NotificationSettingsDialogProps = { type NotificationSettingsDialogProps = {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> name: NotificationSettingsPreferenceName
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode titleText: React.ReactNode
subtitleText: React.ReactNode subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit<NotificationSettingsDialogProps, 'icon'>) { }: Omit<NotificationSettingsDialogProps, 'icon'>) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery() const isChat = isChatPreferenceName(name)
const appQuery = useNotificationSettingsQuery({enabled: !isChat})
const chatQuery = useChatNotificationSettingsQuery({enabled: isChat})
const isError = isChat ? chatQuery.isError : appQuery.isError
const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name]
return ( return (
<> <>
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
<PreferenceControls <PreferenceControls
name={name} name={name}
syncOthers={syncOthers} syncOthers={syncOthers}
preference={preferences?.[name]} preference={preference}
allowDisableInApp={allowDisableInApp} allowDisableInApp={allowDisableInApp}
/> />
)} )}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
<Dialog.Close /> <Dialog.Close />
{IS_NATIVE && ( {IS_NATIVE && (
<Button <Button
color="secondary" color="primary"
size="large" size="large"
label={l`Close dialog`} label={l`Close dialog`}
onPress={() => control.close()} onPress={() => control.close()}
+11 -14
View File
@@ -44,12 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme, utils} from '#/alf' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies' import {useMessageReplies} from '#/components/dms/MessageReplies'
import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -834,6 +835,7 @@ function ReplyQuote({
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const getReplyPreviewText = useReplyPreviewText()
const senderProfile = useMaybeProfileShadow( const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did), relatedProfiles.get(replyTo.sender.did),
@@ -857,22 +859,15 @@ function ReplyQuote({
let text: string let text: string
let subtle = false let subtle = false
if (isBlocked) { if (isBlocked) {
text = l`Blocked message hidden` text = l({
message: '(blocked message hidden)',
comment: 'A reply summary in chat',
})
subtle = true subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) { } else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
text = replyTo.text ;({text, subtle} = getReplyPreviewText(replyTo))
if (!text.trim()) {
subtle = true
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
text = l`(chat invite link)`
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
text = l`(contains embedded content)`
} else { } else {
text = l`No text` text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
}
}
} else {
text = l`Deleted message`
subtle = true subtle = true
} }
@@ -888,6 +883,8 @@ function ReplyQuote({
a.mb_xs, a.mb_xs,
a.rounded_md, a.rounded_md,
a.p_sm, a.p_sm,
// The padding above is a little loose, so we tighten it up here.
{paddingTop: tokens.space.sm - 2},
a.flex_col, a.flex_col,
a.align_start, a.align_start,
a.border, a.border,
+112
View File
@@ -0,0 +1,112 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedRecord,
type ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
/**
* Describes the embed of a quoted message that has no text of its own, so the
* reply preview can show what was shared instead of a generic placeholder. For
* a link card we surface the URI directly; otherwise we classify the quoted
* record (post/feed/list/etc.) so the caller can render a translated label.
*/
type ReplyEmbedSummary =
| {type: 'external'; uri: string}
| {type: 'post'}
| {type: 'unknown'}
function summarizeReplyEmbed(
embed: ChatBskyConvoDefs.MessageView['embed'],
): ReplyEmbedSummary {
if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
const {record} = embed
if (AppBskyEmbedRecord.isViewRecord(record)) {
const inner = record.embeds?.[0]
if (AppBskyEmbedExternal.isView(inner)) {
return {type: 'external', uri: inner.external.uri}
}
return {type: 'post'}
}
return {type: 'unknown'}
}
/**
* Returns a formatter that computes the preview text for a message being quoted
* in a reply, shared between the staged-reply composer and the sent reply
* bubble so the two stay in sync. When the message has its own text we use it
* verbatim; otherwise we summarize the embed.
*
* `subtle` indicates the text is a placeholder (not real message content), so
* callers can render it in a muted/italic style.
*/
export function useReplyPreviewText(): (
message: ChatBskyConvoDefs.MessageView,
) => {text: string; subtle: boolean} {
const {t: l} = useLingui()
return (message: ChatBskyConvoDefs.MessageView) => {
const text = message.text
if (text.trim()) {
return {text, subtle: false}
}
if (ChatBskyEmbedJoinLink.isView(message.embed)) {
const {joinLinkPreview} = message.embed
if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return {
text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
subtle: true,
}
}
if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(disabled chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(invalid chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
return {
text: l({
message: '(chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
const summary = summarizeReplyEmbed(message.embed)
switch (summary.type) {
case 'external':
return {text: toShortUrl(summary.uri), subtle: true}
case 'post':
return {
text: l({
message: '(quoted post)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
default:
return {
text: l({message: '(no text)', comment: 'A reply summary in chat'}),
subtle: true,
}
}
}
}
+7 -13
View File
@@ -2,7 +2,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api' import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro' import {Plural} from '@lingui/react/macro'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -60,23 +60,17 @@ export function LabelsOnMe({
<ButtonIcon position="left" icon={CircleInfo} /> <ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}> <ButtonText style={[a.leading_snug]}>
{type === 'account' ? ( {type === 'account' ? (
<Trans>
<Plural <Plural
value={labels.length} value={labels.length}
one="# label has" one="# account label"
other="# labels have" other="# account labels"
/>{' '} />
been placed on this account
</Trans>
) : ( ) : (
<Trans>
<Plural <Plural
value={labels.length} value={labels.length}
one="# label has" one="# content label"
other="# labels have" other="# content labels"
/>{' '} />
been placed on this content
</Trans>
)} )}
</ButtonText> </ButtonText>
</Button> </Button>
+38 -40
View File
@@ -1,8 +1,7 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native' import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -108,6 +107,7 @@ let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() { export function useNotificationsHandler() {
const ax = useAnalytics() const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
const logger = ax.logger.useChild(ax.logger.Context.Notifications) const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
@@ -116,7 +116,7 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui() const {t: l} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android // On Android, we cannot control which sound is used for a notification on Android
// 28 or higher. Instead, we have to configure a notification channel ahead of time // 28 or higher. Instead, we have to configure a notification channel ahead of time
@@ -129,14 +129,12 @@ export function useNotificationsHandler() {
// NOTE: I don't think that it will retroactively move them into the group // NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn // if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat' const CHAT_GROUP = 'chat'
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, { void Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: _(msg`Chat`), name: l`Chat`,
description: _( description: l`You can choose whether chat notifications have sound in the chat settings within the app`,
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
),
}) })
Notifications.setNotificationChannelAsync('chat-messages', { void Notifications.setNotificationChannelAsync('chat-messages', {
name: _(msg`Chat messages - sound`), name: l`Chat messages - sound`,
groupId: CHAT_GROUP, groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX, importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3', sound: 'dm.mp3',
@@ -144,8 +142,8 @@ export function useNotificationsHandler() {
vibrationPattern: [250], vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
}) })
Notifications.setNotificationChannelAsync('chat-messages-muted', { void Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: _(msg`Chat messages - silent`), name: l`Chat messages - silent`,
groupId: CHAT_GROUP, groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX, importance: Notifications.AndroidImportance.MAX,
sound: null, sound: null,
@@ -154,70 +152,70 @@ export function useNotificationsHandler() {
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
}) })
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'like' satisfies NotificationReason, 'like' satisfies NotificationReason,
{ {
name: _(msg`Likes`), name: l`Likes`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'repost' satisfies NotificationReason, 'repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts`), name: l`Reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'reply' satisfies NotificationReason, 'reply' satisfies NotificationReason,
{ {
name: _(msg`Replies`), name: l`Replies`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'mention' satisfies NotificationReason, 'mention' satisfies NotificationReason,
{ {
name: _(msg`Mentions`), name: l`Mentions`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'quote' satisfies NotificationReason, 'quote' satisfies NotificationReason,
{ {
name: _(msg`Quotes`), name: l`Quotes`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'follow' satisfies NotificationReason, 'follow' satisfies NotificationReason,
{ {
name: _(msg`New followers`), name: l`New followers`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies NotificationReason, 'like-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Likes of your reposts`), name: l`Likes of your reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies NotificationReason, 'repost-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts of your reposts`), name: l`Reposts of your reposts`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( void Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason, 'subscribed-post' satisfies NotificationReason,
{ {
name: _(msg`Activity from others`), name: l`Activity from others`,
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
}, [_]) }, [l])
useEffect(() => { useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => { const handleNotification = (payload?: NotificationPayload) => {
@@ -237,7 +235,7 @@ export function useNotificationsHandler() {
const account = accounts.find(a => a.did === payload.recipientDid) const account = accounts.find(a => a.did === payload.recipientDid)
if (account) { if (account) {
onPressSwitchAccount(account, 'Notification') void onPressSwitchAccount(account, 'Notification')
} else { } else {
setShowLoggedOut(true) setShowLoggedOut(true)
} }
@@ -305,10 +303,10 @@ export function useNotificationsHandler() {
} }
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async e => { handleNotification: e => {
const payload = getNotificationPayload(e) const payload = getNotificationPayload(e)
if (!payload) return DEFAULT_HANDLER_OPTIONS if (!payload) return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
logger.debug('useNotificationsHandler: incoming', {e, payload}) logger.debug('useNotificationsHandler: incoming', {e, payload})
@@ -323,17 +321,17 @@ export function useNotificationsHandler() {
payload.reason === 'chat-removed-from-group' || payload.reason === 'chat-removed-from-group' ||
payload.reason === 'chat-join-request-rejected' || payload.reason === 'chat-join-request-rejected' ||
payload.convoId !== currentConvoId payload.convoId !== currentConvoId
return { return Promise.resolve({
shouldShowList: shouldAlert, shouldShowList: shouldAlert,
shouldShowBanner: shouldAlert, shouldShowBanner: shouldAlert,
shouldPlaySound: false, shouldPlaySound: false,
shouldSetBadge: false, shouldSetBadge: false,
} satisfies Notifications.NotificationBehavior } satisfies Notifications.NotificationBehavior)
} }
// Any notification other than a chat message should invalidate the unread page // Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage() invalidateCachedUnreadPage()
return DEFAULT_HANDLER_OPTIONS return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
}, },
}) })
@@ -363,14 +361,14 @@ export function useNotificationsHandler() {
}) })
invalidateCachedUnreadPage() invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if ( if (
payload.reason === 'mention' || payload.reason === 'mention' ||
payload.reason === 'quote' || payload.reason === 'quote' ||
payload.reason === 'reply' payload.reason === 'reply'
) { ) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
} }
logger.debug('Notifications: handleNotification', { logger.debug('Notifications: handleNotification', {
@@ -379,7 +377,7 @@ export function useNotificationsHandler() {
}) })
handleNotification(payload) handleNotification(payload)
Notifications.dismissAllNotificationsAsync() void Notifications.dismissAllNotificationsAsync()
// Also clear the native `lastResponse` cache. Otherwise a subsequent // Also clear the native `lastResponse` cache. Otherwise a subsequent
// `getLastNotificationResponse()` (e.g. on an account-switch remount, // `getLastNotificationResponse()` (e.g. on an account-switch remount,
// which re-runs `handlePushNotificationEntry`) would replay this // which re-runs `handlePushNotificationEntry`) would replay this
+10 -13
View File
@@ -6,11 +6,7 @@ import {
VIDEO_MAX_SIZE, VIDEO_MAX_SIZE,
} from '#/lib/constants' } from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
compress,
probe,
type VideoMetadata,
} from '../../../../modules/expo-bluesky-video-compress'
import { import {
COMPRESSION_MAX_DIMENSION, COMPRESSION_MAX_DIMENSION,
COMPRESSION_PASSTHROUGH_BITRATE, COMPRESSION_PASSTHROUGH_BITRATE,
@@ -23,12 +19,15 @@ export async function compressVideo(
opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
onProbe?: (metadata: VideoMetadata, wouldCompress: boolean) => void
onProbeFailed?: (error: unknown) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
if (file.mimeType === 'image/gif') { if (file.mimeType === 'image/gif') {
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} return {
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: 'image/gif',
passthroughReason: 'gif',
}
} }
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
@@ -42,22 +41,20 @@ export async function compressVideo(
logger.debug('probe failed, falling through to passthrough', { logger.debug('probe failed, falling through to passthrough', {
safeMessage: e, safeMessage: e,
}) })
opts?.onProbeFailed?.(e)
return { return {
uri: file.uri, uri: file.uri,
size: file.fileSize ?? -1, size: file.fileSize ?? -1,
mimeType: file.mimeType ?? 'video/mp4', mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'compress-error-fallback',
} }
} }
const willCompress = shouldCompress(metadata, isAcceptableFormat) if (!shouldCompress(metadata, isAcceptableFormat)) {
opts?.onProbe?.(metadata, willCompress)
if (!willCompress) {
return { return {
uri: file.uri, uri: file.uri,
size: metadata.fileSize, size: metadata.fileSize,
mimeType: file.mimeType ?? 'video/mp4', mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'below-thresholds',
} }
} }
+16 -4
View File
@@ -47,6 +47,7 @@ export async function compressVideo(
const blob = await response.blob() const blob = await response.blob()
const isGif = blob.type === 'image/gif' const isGif = blob.type === 'image/gif'
const hasCodecs = hasWebCodecs()
logger.debug('compress: fetched blob', { logger.debug('compress: fetched blob', {
size: blob.size, size: blob.size,
@@ -57,21 +58,31 @@ export async function compressVideo(
// Try MediaBunny compression if WebCodecs is available and file is large enough // Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them // Skip GIFs - MediaBunny doesn't support them
if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) { let fallbackReason: NonNullable<CompressedVideo['passthroughReason']> | null =
null
if (isGif) {
fallbackReason = 'gif'
} else if (!hasCodecs) {
fallbackReason = 'no-webcodecs'
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
fallbackReason = 'below-byte-threshold'
} else {
try { try {
return await doCompression(blob, asset.uri, {onProgress, signal}) return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) { } catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', { logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e, safeMessage: e,
}) })
fallbackReason = 'compress-error-fallback'
} }
} else { }
logger.debug('compress: skipping compression', { logger.debug('compress: skipping compression', {
hasWebCodecs: hasWebCodecs(), hasWebCodecs: hasCodecs,
blobSize: blob.size, blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES, minSize: COMPRESSION_MIN_SIZE_BYTES,
reason: fallbackReason,
}) })
}
// No compression path - just return the blob as-is // No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) { if (blob.size > VIDEO_MAX_SIZE) {
@@ -83,6 +94,7 @@ export async function compressVideo(
size: blob.size, size: blob.size,
bytes: await blob.arrayBuffer(), bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4', mimeType: blob.type || 'video/mp4',
passthroughReason: fallbackReason,
} }
} }
+271
View File
@@ -0,0 +1,271 @@
import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {nanoid} from 'nanoid/non-secure'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
// Identifies the active compression engine. Bumped when the engine swaps.
// Versions are intentionally hardcoded so a dependency bump shows up in
// analytics as a label change.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:mediabunny@1.25.3'
: 'native:react-native-compressor@1.13.0'
type Phase = 'compress' | 'upload' | 'processing'
function errorClass(e: unknown): string {
if (e instanceof Error) return e.name || 'Error'
return 'Unknown'
}
export type VideoTelemetry = {
readonly uploadId: string
readonly engine: string
picked: () => void
compressStarted: () => void
compressSkipped: (video: {
size: number
mimeType: string
skipReason: VideoCompressSkipReason
}) => void
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
processingCompleted: () => void
processingFailed: (e: unknown) => void
published: () => void
}
export function createVideoTelemetry({
asset,
signal,
metric,
}: {
asset: ImagePickerAsset
signal: AbortSignal
metric: MetricFn
}): VideoTelemetry {
const uploadId = nanoid()
const engine = COMPRESS_ENGINE
const startedAt = Date.now()
let phase: Phase | undefined
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let txnEnded = false
let abortBound = true
// Parent span: full selection->ready arc. Inactive so phase spans can be
// attached as children regardless of the current async context.
const txn = Sentry.startInactiveSpan({
name: 'video.upload',
op: 'video.upload',
attributes: {
uploadId,
engine,
'video.source.mime': asset.mimeType ?? 'unknown',
'video.source.bytes': asset.fileSize ?? 0,
'video.source.durationMs': asset.duration ?? 0,
'video.source.width': asset.width ?? 0,
'video.source.height': asset.height ?? 0,
},
})
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
function endPhaseSpan() {
if (!phaseSpan) return
phaseSpan.end()
phaseSpan = undefined
}
function enterPhase(next: Phase, spanName: string) {
endPhaseSpan()
phase = next
phaseStartedAt = Date.now()
phaseSpan = Sentry.withActiveSpan(txn, () =>
Sentry.startInactiveSpan({
name: spanName,
op: spanName,
attributes: {uploadId, engine},
}),
)
}
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
if (txnEnded) return
txnEnded = true
endPhaseSpan()
txn.setAttribute('outcome', outcome)
txn.end()
}
function detachAbort() {
if (!abortBound) return
abortBound = false
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
if (phase) {
metric('video:upload:abandoned', {
uploadId,
engine,
phase,
jobId,
elapsedInPhaseMs: Date.now() - phaseStartedAt,
})
}
endTxn('cancelled')
abortBound = false
}
signal.addEventListener('abort', onAbort, {once: true})
return {
uploadId,
engine,
picked() {
metric('video:upload:picked', {
uploadId,
engine,
sourceMimeType: asset.mimeType,
sourceBytes: asset.fileSize,
sourceDurationMs: asset.duration ?? undefined,
sourceWidth: asset.width,
sourceHeight: asset.height,
})
},
compressStarted() {
enterPhase('compress', 'video.compress')
metric('video:upload:compressStarted', {
uploadId,
engine,
sourceBytes: asset.fileSize,
})
},
compressSkipped({size, mimeType, skipReason}) {
metric('video:upload:compressSkipped', {
uploadId,
engine,
skipReason,
bytes: size,
mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressCompleted({size, mimeType}) {
metric('video:upload:compressCompleted', {
uploadId,
engine,
bytesIn: asset.fileSize,
bytesOut: size,
outputMimeType: mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressFailed(e) {
metric('video:upload:compressFailed', {
uploadId,
engine,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
uploadStarted(bytes) {
uploadBytes = bytes
enterPhase('upload', 'video.upload.transfer')
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
const bytes = uploadBytes ?? 0
metric('video:upload:uploadCompleted', {
uploadId,
engine,
jobId: id,
bytes,
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
})
endPhaseSpan()
phase = undefined
},
uploadFailed(e) {
metric('video:upload:uploadFailed', {
uploadId,
engine,
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
processingStarted(id) {
jobId = id
enterPhase('processing', 'video.processing')
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
},
processingCompleted() {
metric('video:upload:processingCompleted', {
uploadId,
engine,
jobId: jobId ?? '',
elapsedMs: Date.now() - phaseStartedAt,
})
// Upload pipeline is done; publish is a separate user action that
// fires its own event. Releases the parent span so its duration
// measures upload work, not idle composer time.
endTxn('ok')
detachAbort()
},
processingFailed(e) {
metric('video:upload:processingFailed', {
uploadId,
engine,
jobId: jobId ?? '',
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
published() {
metric('video:upload:published', {
uploadId,
engine,
jobId: jobId ?? '',
totalElapsedMs: Date.now() - startedAt,
})
},
}
}
+14
View File
@@ -1,7 +1,21 @@
// Why the compress engine returned the input unchanged. Used both as the
// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field
// on the `video:upload:compressSkipped` analytics event, so the two stay in
// sync.
export type VideoCompressSkipReason =
| 'gif'
| 'below-byte-threshold'
| 'below-thresholds'
| 'no-webcodecs'
| 'compress-error-fallback'
export type CompressedVideo = { export type CompressedVideo = {
uri: string uri: string
mimeType: string mimeType: string
size: number size: number
// web only, can fall back to uri if missing // web only, can fall back to uri if missing
bytes?: ArrayBuffer bytes?: ArrayBuffer
// Set when the engine returned the input unchanged. Undefined means the
// bytes were actually re-encoded.
passthroughReason?: VideoCompressSkipReason
} }
+116 -110
View File
@@ -1956,10 +1956,10 @@ msgstr ""
#: src/components/dms/dialogs/NewChatDialog.tsx:155 #: src/components/dms/dialogs/NewChatDialog.tsx:155
#: src/components/dms/MessageProfileButton.tsx:60 #: src/components/dms/MessageProfileButton.tsx:60
#: src/screens/Messages/ChatList.tsx:155 #: src/screens/Messages/ChatList.tsx:166
#: src/screens/Messages/ChatList.tsx:173 #: src/screens/Messages/ChatList.tsx:184
#: src/screens/Messages/ChatList.tsx:274 #: src/screens/Messages/ChatList.tsx:285
#: src/screens/Messages/ChatList.tsx:527 #: src/screens/Messages/ChatList.tsx:541
#: src/screens/Messages/Conversation.tsx:203 #: src/screens/Messages/Conversation.tsx:203
#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:99 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:99
msgid "Before you can message another user, you must first verify your email." msgid "Before you can message another user, you must first verify your email."
@@ -2065,7 +2065,7 @@ msgstr ""
msgid "Block user and/or leave this conversation" msgid "Block user and/or leave this conversation"
msgstr "Block user and/or leave this conversation" msgstr "Block user and/or leave this conversation"
#: src/components/Post/Embed/index.tsx:216 #: src/components/Post/Embed/index.tsx:212
msgid "Blocked" msgid "Blocked"
msgstr "" msgstr ""
@@ -2335,7 +2335,7 @@ msgstr "Camera access needed"
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:299 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:299
#: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:175 #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:175
#: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:181 #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:181
#: src/components/Menu/index.tsx:367 #: src/components/Menu/index.tsx:373
#: src/components/moderation/BlockDialog.tsx:202 #: src/components/moderation/BlockDialog.tsx:202
#: src/components/PostControls/RepostButton.tsx:210 #: src/components/PostControls/RepostButton.tsx:210
#: src/components/Prompt.tsx:152 #: src/components/Prompt.tsx:152
@@ -2531,6 +2531,12 @@ msgstr ""
msgid "Chat not found." msgid "Chat not found."
msgstr "Chat not found." msgstr "Chat not found."
#: src/screens/Messages/ChatList.tsx:567
#: src/screens/Messages/ChatList.tsx:602
#: src/screens/Messages/ChatList.tsx:640
msgid "Chat options"
msgstr "Chat options"
#: src/components/moderation/BlockDialog.tsx:293 #: src/components/moderation/BlockDialog.tsx:293
msgid "Chat owners cannot leave a group chat." msgid "Chat owners cannot leave a group chat."
msgstr "Chat owners cannot leave a group chat." msgstr "Chat owners cannot leave a group chat."
@@ -2550,10 +2556,10 @@ msgstr ""
#: src/components/dms/ConvoMenu.tsx:88 #: src/components/dms/ConvoMenu.tsx:88
#: src/Navigation.tsx:506 #: src/Navigation.tsx:506
#: src/screens/Messages/ChatList.tsx:84 #: src/screens/Messages/ChatList.tsx:95
#: src/screens/Messages/ChatList.tsx:88 #: src/screens/Messages/ChatList.tsx:99
#: src/screens/Messages/ChatList.tsx:552 #: src/screens/Messages/ChatList.tsx:652
#: src/screens/Messages/ChatList.tsx:583 #: src/screens/Messages/ChatList.tsx:662
#: src/screens/Messages/Settings.tsx:39 #: src/screens/Messages/Settings.tsx:39
msgid "Chat settings" msgid "Chat settings"
msgstr "" msgstr ""
@@ -2580,9 +2586,9 @@ msgctxt "toast"
msgid "Chat unmuted" msgid "Chat unmuted"
msgstr "" msgstr ""
#: src/screens/Messages/ChatList.tsx:78 #: src/screens/Messages/ChatList.tsx:89
#: src/screens/Messages/ChatList.tsx:539 #: src/screens/Messages/ChatList.tsx:553
#: src/screens/Messages/ChatList.tsx:576 #: src/screens/Messages/ChatList.tsx:593
msgid "Chats" msgid "Chats"
msgstr "" msgstr ""
@@ -2656,7 +2662,7 @@ msgstr ""
msgid "Choose your password" msgid "Choose your password"
msgstr "" msgstr ""
#: src/screens/Signup/index.tsx:192 #: src/screens/Signup/index.tsx:193
msgid "Choose your username" msgid "Choose your username"
msgstr "" msgstr ""
@@ -2857,7 +2863,7 @@ msgstr "Close the incoming requests banner"
#: src/components/intents/GroupChatJoinDialog.tsx:240 #: src/components/intents/GroupChatJoinDialog.tsx:240
#: src/components/intents/GroupChatJoinDialog.tsx:276 #: src/components/intents/GroupChatJoinDialog.tsx:276
#: src/components/intents/GroupChatJoinDialog.tsx:277 #: src/components/intents/GroupChatJoinDialog.tsx:277
#: src/components/Menu/index.tsx:361 #: src/components/Menu/index.tsx:367
msgid "Close this dialog" msgid "Close this dialog"
msgstr "" msgstr ""
@@ -2881,7 +2887,7 @@ msgstr ""
msgid "Collapses list of users for a given notification" msgid "Collapses list of users for a given notification"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:66 #: src/features/inviteFriends/components/ThemePicker.tsx:68
msgid "Color" msgid "Color"
msgstr "Color" msgstr "Color"
@@ -2912,7 +2918,7 @@ msgstr ""
msgid "Complete onboarding and start using your account" msgid "Complete onboarding and start using your account"
msgstr "" msgstr ""
#: src/screens/Signup/index.tsx:194 #: src/screens/Signup/index.tsx:195
msgid "Complete the challenge" msgid "Complete the challenge"
msgstr "" msgstr ""
@@ -2989,8 +2995,8 @@ msgid "Contact our support team"
msgstr "" msgstr ""
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:156 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:156
#: src/screens/Signup/index.tsx:233 #: src/screens/Signup/index.tsx:240
#: src/screens/Signup/index.tsx:236 #: src/screens/Signup/index.tsx:243
msgid "Contact support" msgid "Contact support"
msgstr "" msgstr ""
@@ -3130,7 +3136,7 @@ msgid "Copied build version to clipboard"
msgstr "" msgstr ""
#: src/components/dms/ChatInvite/Root.tsx:99 #: src/components/dms/ChatInvite/Root.tsx:99
#: src/components/dms/MessageContextMenu.tsx:83 #: src/components/dms/MessageContextMenu.tsx:97
#: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/DiscoverDebug.tsx:36
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:273 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:273
#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77
@@ -3222,8 +3228,8 @@ msgstr ""
msgid "Copy link to starter pack" msgid "Copy link to starter pack"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:183 #: src/components/dms/MessageContextMenu.tsx:198
#: src/components/dms/MessageContextMenu.tsx:187 #: src/components/dms/MessageContextMenu.tsx:202
msgid "Copy message text" msgid "Copy message text"
msgstr "" msgstr ""
@@ -3398,7 +3404,7 @@ msgstr ""
#: src/components/WelcomeModal.tsx:158 #: src/components/WelcomeModal.tsx:158
#: src/components/WelcomeModal.tsx:166 #: src/components/WelcomeModal.tsx:166
#: src/screens/Messages/JoinRequest.tsx:310 #: src/screens/Messages/JoinRequest.tsx:310
#: src/screens/Signup/index.tsx:135 #: src/screens/Signup/index.tsx:136
#: src/view/com/auth/SplashScreen.tsx:107 #: src/view/com/auth/SplashScreen.tsx:107
#: src/view/com/auth/SplashScreen.web.tsx:116 #: src/view/com/auth/SplashScreen.web.tsx:116
#: src/view/shell/bottom-bar/BottomBar.tsx:345 #: src/view/shell/bottom-bar/BottomBar.tsx:345
@@ -3544,7 +3550,7 @@ msgstr ""
msgid "Dawn" msgid "Dawn"
msgstr "Dawn" msgstr "Dawn"
#: src/features/inviteFriends/components/ThemePicker.tsx:29 #: src/features/inviteFriends/components/ThemePicker.tsx:30
msgid "Day" msgid "Day"
msgstr "Day" msgstr "Day"
@@ -3627,7 +3633,7 @@ msgstr ""
msgid "Delete conversation" msgid "Delete conversation"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:197 #: src/components/dms/MessageContextMenu.tsx:212
msgid "Delete for me" msgid "Delete for me"
msgstr "" msgstr ""
@@ -3640,7 +3646,7 @@ msgstr ""
msgid "Delete message" msgid "Delete message"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:194 #: src/components/dms/MessageContextMenu.tsx:209
msgid "Delete message for me" msgid "Delete message for me"
msgstr "" msgstr ""
@@ -3671,7 +3677,7 @@ msgstr ""
msgid "Delete this post?" msgid "Delete this post?"
msgstr "" msgstr ""
#: src/components/Post/Embed/index.tsx:209 #: src/components/Post/Embed/index.tsx:205
msgid "Deleted" msgid "Deleted"
msgstr "" msgstr ""
@@ -4040,7 +4046,7 @@ msgstr ""
msgid "Duration:" msgid "Duration:"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:30 #: src/features/inviteFriends/components/ThemePicker.tsx:31
msgid "Dusk" msgid "Dusk"
msgstr "Dusk" msgstr "Dusk"
@@ -4171,12 +4177,12 @@ msgstr ""
#: src/screens/Profile/Header/EditProfileDialog.tsx:265 #: src/screens/Profile/Header/EditProfileDialog.tsx:265
#: src/screens/Profile/Header/EditProfileDialog.tsx:271 #: src/screens/Profile/Header/EditProfileDialog.tsx:271
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:296 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:299
#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:350 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:350
msgid "Edit profile" msgid "Edit profile"
msgstr "" msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:299 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:302
#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:352 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:352
msgid "Edit Profile" msgid "Edit Profile"
msgstr "" msgstr ""
@@ -4598,7 +4604,7 @@ msgid "Failed to accept join request"
msgstr "Failed to accept join request" msgstr "Failed to accept join request"
#: src/components/dms/ActionsWrapper.web.tsx:92 #: src/components/dms/ActionsWrapper.web.tsx:92
#: src/components/dms/MessageContextMenu.tsx:126 #: src/components/dms/MessageContextMenu.tsx:140
msgid "Failed to add emoji reaction" msgid "Failed to add emoji reaction"
msgstr "" msgstr ""
@@ -4693,10 +4699,6 @@ msgstr ""
msgid "Failed to hide suggestion, please check your internet connection" msgid "Failed to hide suggestion, please check your internet connection"
msgstr "" msgstr ""
#: src/screens/Messages/JoinRequests.tsx:223
msgid "Failed to ignore join request"
msgstr "Failed to ignore join request"
#: src/components/intents/GroupChatJoinDialog.tsx:147 #: src/components/intents/GroupChatJoinDialog.tsx:147
msgid "Failed to join the group chat. Please try again." msgid "Failed to join the group chat. Please try again."
msgstr "Failed to join the group chat. Please try again." msgstr "Failed to join the group chat. Please try again."
@@ -4712,7 +4714,7 @@ msgctxt "toast"
msgid "Failed to leave group chat" msgid "Failed to leave group chat"
msgstr "Failed to leave group chat" msgstr "Failed to leave group chat"
#: src/screens/Messages/ChatList.tsx:404 #: src/screens/Messages/ChatList.tsx:418
#: src/screens/Messages/Inbox.tsx:209 #: src/screens/Messages/Inbox.tsx:209
msgid "Failed to load conversations" msgid "Failed to load conversations"
msgstr "" msgstr ""
@@ -4762,6 +4764,7 @@ msgstr ""
msgid "Failed to lock group chat" msgid "Failed to lock group chat"
msgstr "Failed to lock group chat" msgstr "Failed to lock group chat"
#: src/screens/Messages/ChatList.tsx:634
#: src/view/shell/bottom-bar/BottomBar.tsx:441 #: src/view/shell/bottom-bar/BottomBar.tsx:441
msgid "Failed to mark all chats as read" msgid "Failed to mark all chats as read"
msgstr "Failed to mark all chats as read" msgstr "Failed to mark all chats as read"
@@ -4784,6 +4787,10 @@ msgstr ""
msgid "Failed to reconnect Germ DM. Error: {0}" msgid "Failed to reconnect Germ DM. Error: {0}"
msgstr "" msgstr ""
#: src/screens/Messages/JoinRequests.tsx:223
msgid "Failed to reject join request"
msgstr "Failed to reject join request"
#: src/screens/Settings/FindContactsSettings.tsx:509 #: src/screens/Settings/FindContactsSettings.tsx:509
msgid "Failed to remove data due to a network error, please check your internet connection." msgid "Failed to remove data due to a network error, please check your internet connection."
msgstr "" msgstr ""
@@ -4794,7 +4801,7 @@ msgid "Failed to remove data. {0}"
msgstr "" msgstr ""
#: src/components/dms/ActionsWrapper.web.tsx:77 #: src/components/dms/ActionsWrapper.web.tsx:77
#: src/components/dms/MessageContextMenu.tsx:111 #: src/components/dms/MessageContextMenu.tsx:125
#: src/components/dms/ReactionsDialog.tsx:179 #: src/components/dms/ReactionsDialog.tsx:179
msgid "Failed to remove emoji reaction" msgid "Failed to remove emoji reaction"
msgstr "" msgstr ""
@@ -5779,7 +5786,7 @@ msgstr ""
msgid "Have we got your location wrong? <0>Tap here to update your location with GPS.</0>" msgid "Have we got your location wrong? <0>Tap here to update your location with GPS.</0>"
msgstr "Have we got your location wrong? <0>Tap here to update your location with GPS.</0>" msgstr "Have we got your location wrong? <0>Tap here to update your location with GPS.</0>"
#: src/screens/Signup/index.tsx:231 #: src/screens/Signup/index.tsx:238
msgid "Having trouble?" msgid "Having trouble?"
msgstr "" msgstr ""
@@ -6224,7 +6231,7 @@ msgstr "In-app, push, everyone"
msgid "In-app, push, people you follow" msgid "In-app, push, people you follow"
msgstr "In-app, push, people you follow" msgstr "In-app, push, people you follow"
#: src/screens/Messages/ChatList.tsx:421 #: src/screens/Messages/ChatList.tsx:435
msgid "Inbox empty" msgid "Inbox empty"
msgstr "Inbox empty" msgstr "Inbox empty"
@@ -6474,11 +6481,6 @@ msgstr ""
msgid "Keep me posted" msgid "Keep me posted"
msgstr "" msgstr ""
#: src/components/moderation/BlockDialog.tsx:365
#: src/components/moderation/BlockDialog.tsx:372
msgid "Kick member"
msgstr "Kick member"
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:200 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:200
msgid "KWS website" msgid "KWS website"
msgstr "" msgstr ""
@@ -6949,9 +6951,7 @@ msgstr ""
msgid "Load new posts" msgid "Load new posts"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:245 #: src/screens/Messages/components/MessageComposer.tsx:254
#: src/screens/Messages/components/MessageInput.tsx:258
#: src/screens/Messages/components/MessageInput.web.tsx:228
msgctxt "placeholder" msgctxt "placeholder"
msgid "Loading chat…" msgid "Loading chat…"
msgstr "Loading chat…" msgstr "Loading chat…"
@@ -7065,6 +7065,8 @@ msgstr ""
msgid "Mark all as read" msgid "Mark all as read"
msgstr "" msgstr ""
#: src/screens/Messages/ChatList.tsx:644
#: src/screens/Messages/ChatList.tsx:648
#: src/view/shell/bottom-bar/BottomBar.tsx:459 #: src/view/shell/bottom-bar/BottomBar.tsx:459
#: src/view/shell/bottom-bar/BottomBar.tsx:463 #: src/view/shell/bottom-bar/BottomBar.tsx:463
msgid "Mark all chats as read" msgid "Mark all chats as read"
@@ -7084,6 +7086,7 @@ msgstr ""
msgid "Marked all as read" msgid "Marked all as read"
msgstr "" msgstr ""
#: src/screens/Messages/ChatList.tsx:631
#: src/view/shell/bottom-bar/BottomBar.tsx:438 #: src/view/shell/bottom-bar/BottomBar.tsx:438
msgid "Marked all chats as read" msgid "Marked all chats as read"
msgstr "Marked all chats as read" msgstr "Marked all chats as read"
@@ -7139,19 +7142,17 @@ msgstr ""
msgid "Mentions" msgid "Mentions"
msgstr "" msgstr ""
#: src/components/Menu/index.tsx:113 #: src/components/Menu/index.tsx:119
msgid "Menu" msgid "Menu"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageInput.tsx:202 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203
msgctxt "action"
msgid "Message" msgid "Message"
msgstr "Message" msgstr "Message"
#: src/screens/Messages/components/MessageComposer.tsx:246 #: src/screens/Messages/components/MessageComposer.tsx:255
#: src/screens/Messages/components/MessageInput.tsx:259 msgctxt "description"
#: src/screens/Messages/components/MessageInput.web.tsx:229
#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203
msgctxt "action"
msgid "Message" msgid "Message"
msgstr "Message" msgstr "Message"
@@ -7179,7 +7180,7 @@ msgstr "Message failed to send."
#. placeholder {0}: sender?.handle ?? 'unknown' #. placeholder {0}: sender?.handle ?? 'unknown'
#. placeholder {1}: message.text #. placeholder {1}: message.text
#: src/components/dms/MessageContextMenu.tsx:152 #: src/components/dms/MessageContextMenu.tsx:166
msgid "Message from @{0}: {1}" msgid "Message from @{0}: {1}"
msgstr "" msgstr ""
@@ -7188,21 +7189,15 @@ msgstr ""
msgid "Message from server: {0}" msgid "Message from server: {0}"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:242 #: src/screens/Messages/components/MessageComposer.tsx:251
#: src/screens/Messages/components/MessageInput.tsx:200
msgid "Message input field" msgid "Message input field"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageInput.tsx:96 #: src/screens/Messages/components/MessageComposer.tsx:116
#: src/screens/Messages/components/MessageInput.web.tsx:65
msgid "Message is too long"
msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:107
msgid "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" msgid "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})"
msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})"
#: src/components/dms/MessageContextMenu.tsx:151 #: src/components/dms/MessageContextMenu.tsx:165
msgid "Message options" msgid "Message options"
msgstr "" msgstr ""
@@ -7510,7 +7505,6 @@ msgstr ""
msgid "Nevermind, create a handle for me" msgid "Nevermind, create a handle for me"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171
#: src/screens/Search/modules/ExploreTrendingTopics.tsx:184 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:184
#: src/view/com/profile/ProfileMenu.tsx:398 #: src/view/com/profile/ProfileMenu.tsx:398
msgid "New" msgid "New"
@@ -7522,6 +7516,11 @@ msgctxt "action"
msgid "New" msgid "New"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171
msgctxt "nux-description"
msgid "New"
msgstr "New"
#: src/view/com/notifications/NotificationFeedItem.tsx:569 #: src/view/com/notifications/NotificationFeedItem.tsx:569
msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}" msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}"
msgstr "" msgstr ""
@@ -7532,11 +7531,11 @@ msgstr ""
#: src/components/dms/dialogs/NewChatDialog.tsx:169 #: src/components/dms/dialogs/NewChatDialog.tsx:169
#: src/components/dms/dialogs/NewChatDialog.tsx:184 #: src/components/dms/dialogs/NewChatDialog.tsx:184
#: src/screens/Messages/ChatList.tsx:218 #: src/screens/Messages/ChatList.tsx:229
#: src/screens/Messages/ChatList.tsx:219 #: src/screens/Messages/ChatList.tsx:230
#: src/screens/Messages/ChatList.tsx:439 #: src/screens/Messages/ChatList.tsx:453
#: src/screens/Messages/ChatList.tsx:440 #: src/screens/Messages/ChatList.tsx:454
#: src/screens/Messages/ChatList.tsx:561 #: src/screens/Messages/ChatList.tsx:578
msgid "New chat" msgid "New chat"
msgstr "" msgstr ""
@@ -7676,7 +7675,7 @@ msgstr ""
msgid "Next image" msgid "Next image"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:31 #: src/features/inviteFriends/components/ThemePicker.tsx:33
msgid "Night" msgid "Night"
msgstr "Night" msgstr "Night"
@@ -8087,8 +8086,8 @@ msgid "Only admins can accept join requests."
msgstr "Only admins can accept join requests." msgstr "Only admins can accept join requests."
#: src/screens/Messages/JoinRequests.tsx:233 #: src/screens/Messages/JoinRequests.tsx:233
msgid "Only admins can ignore join requests." msgid "Only admins can reject join requests."
msgstr "Only admins can ignore join requests." msgstr "Only admins can reject join requests."
#: src/components/intents/GroupChatJoinDialog.tsx:155 #: src/components/intents/GroupChatJoinDialog.tsx:155
msgid "Only followers can join this group chat." msgid "Only followers can join this group chat."
@@ -8165,8 +8164,7 @@ msgstr ""
msgid "Open drawer menu" msgid "Open drawer menu"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:204 #: src/screens/Messages/components/MessageComposer.tsx:213
#: src/screens/Messages/components/MessageInput.web.tsx:174
#: src/view/com/composer/Composer.tsx:2158 #: src/view/com/composer/Composer.tsx:2158
msgid "Open emoji picker" msgid "Open emoji picker"
msgstr "" msgstr ""
@@ -8275,7 +8273,7 @@ msgstr ""
msgid "Opens a dialog to choose who can interact with this post" msgid "Opens a dialog to choose who can interact with this post"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:38 #: src/features/inviteFriends/components/ThemePicker.tsx:40
msgid "Opens a list of color themes for the QR card" msgid "Opens a list of color themes for the QR card"
msgstr "Opens a list of color themes for the QR card" msgstr "Opens a list of color themes for the QR card"
@@ -8605,7 +8603,7 @@ msgstr ""
msgid "Photography" msgid "Photography"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:37 #: src/features/inviteFriends/components/ThemePicker.tsx:39
msgid "Pick a color theme" msgid "Pick a color theme"
msgstr "Pick a color theme" msgstr "Pick a color theme"
@@ -9334,7 +9332,7 @@ msgstr ""
msgid "Reject join request" msgid "Reject join request"
msgstr "Reject join request" msgstr "Reject join request"
#: src/screens/Messages/ChatList.tsx:408 #: src/screens/Messages/ChatList.tsx:422
#: src/screens/Messages/Inbox.tsx:213 #: src/screens/Messages/Inbox.tsx:213
msgid "Reload conversations" msgid "Reload conversations"
msgstr "" msgstr ""
@@ -9461,6 +9459,11 @@ msgstr ""
msgid "Remove live status" msgid "Remove live status"
msgstr "" msgstr ""
#: src/components/moderation/BlockDialog.tsx:365
#: src/components/moderation/BlockDialog.tsx:372
msgid "Remove member"
msgstr "Remove member"
#: src/components/dialogs/MutedWords.tsx:613 #: src/components/dialogs/MutedWords.tsx:613
msgid "Remove mute word from your list" msgid "Remove mute word from your list"
msgstr "" msgstr ""
@@ -9502,11 +9505,11 @@ msgstr ""
msgid "Remove your verification for this account?" msgid "Remove your verification for this account?"
msgstr "" msgstr ""
#: src/components/Post/Embed/index.tsx:244 #: src/components/Post/Embed/index.tsx:240
msgid "Removed by author" msgid "Removed by author"
msgstr "" msgstr ""
#: src/components/Post/Embed/index.tsx:242 #: src/components/Post/Embed/index.tsx:238
msgid "Removed by you" msgid "Removed by you"
msgstr "" msgstr ""
@@ -9604,8 +9607,8 @@ msgstr ""
msgid "Replies to this post are disabled." msgid "Replies to this post are disabled."
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:167 #: src/components/dms/MessageContextMenu.tsx:182
#: src/components/dms/MessageContextMenu.tsx:170 #: src/components/dms/MessageContextMenu.tsx:185
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
@@ -9647,7 +9650,7 @@ msgstr ""
msgid "Reply was successfully hidden" msgid "Reply was successfully hidden"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:205 #: src/components/dms/MessageContextMenu.tsx:220
#: src/features/liveNow/components/LiveStatusDialog.tsx:267 #: src/features/liveNow/components/LiveStatusDialog.tsx:267
#: src/screens/Messages/ConversationSettings/index.tsx:583 #: src/screens/Messages/ConversationSettings/index.tsx:583
msgid "Report" msgid "Report"
@@ -9680,7 +9683,7 @@ msgstr ""
msgid "Report list" msgid "Report list"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:202 #: src/components/dms/MessageContextMenu.tsx:217
msgid "Report message" msgid "Report message"
msgstr "" msgstr ""
@@ -9808,8 +9811,8 @@ msgid "Request code"
msgstr "" msgstr ""
#: src/screens/Messages/JoinRequests.tsx:215 #: src/screens/Messages/JoinRequests.tsx:215
msgid "Request ignored." msgid "Request rejected."
msgstr "Request ignored." msgstr "Request rejected."
#: src/components/dms/ChatInvite/Root.tsx:117 #: src/components/dms/ChatInvite/Root.tsx:117
#: src/components/intents/GroupChatJoinDialog.tsx:295 #: src/components/intents/GroupChatJoinDialog.tsx:295
@@ -9929,7 +9932,7 @@ msgstr ""
#: src/components/StarterPack/ProfileStarterPacks.tsx:377 #: src/components/StarterPack/ProfileStarterPacks.tsx:377
#: src/screens/Login/LoginForm.tsx:329 #: src/screens/Login/LoginForm.tsx:329
#: src/screens/Login/LoginForm.tsx:335 #: src/screens/Login/LoginForm.tsx:335
#: src/screens/Messages/ChatList.tsx:413 #: src/screens/Messages/ChatList.tsx:427
#: src/screens/Messages/components/MessageListError.tsx:24 #: src/screens/Messages/components/MessageListError.tsx:24
#: src/screens/Messages/Inbox.tsx:218 #: src/screens/Messages/Inbox.tsx:218
#: src/screens/Messages/JoinRequests.tsx:361 #: src/screens/Messages/JoinRequests.tsx:361
@@ -10084,8 +10087,8 @@ msgstr ""
msgid "Say hello!" msgid "Say hello!"
msgstr "" msgstr ""
#: src/screens/Messages/ChatList.tsx:209 #: src/screens/Messages/ChatList.tsx:220
#: src/screens/Messages/ChatList.tsx:430 #: src/screens/Messages/ChatList.tsx:444
msgid "Say hi to someone" msgid "Say hi to someone"
msgstr "Say hi to someone" msgstr "Say hi to someone"
@@ -10486,8 +10489,7 @@ msgstr "Send error report"
msgid "Send feedback" msgid "Send feedback"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:316 #: src/screens/Messages/components/MessageComposer.tsx:324
#: src/screens/Messages/components/MessageInput.web.tsx:251
msgid "Send message" msgid "Send message"
msgstr "" msgstr ""
@@ -10521,7 +10523,7 @@ msgid "Send via direct message"
msgstr "" msgstr ""
#. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', }) #. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', })
#: src/components/dms/MessageContextMenu.tsx:161 #: src/components/dms/MessageContextMenu.tsx:175
msgid "Sent at {0}" msgid "Sent at {0}"
msgstr "Sent at {0}" msgstr "Sent at {0}"
@@ -11218,7 +11220,7 @@ msgstr ""
#. placeholder {1}: state.serviceDescription && !state.serviceDescription.phoneVerificationRequired ? '2' : '3' #. placeholder {1}: state.serviceDescription && !state.serviceDescription.phoneVerificationRequired ? '2' : '3'
#. placeholder {1}: state.totalSteps #. placeholder {1}: state.totalSteps
#: src/screens/Onboarding/Layout.tsx:226 #: src/screens/Onboarding/Layout.tsx:226
#: src/screens/Signup/index.tsx:180 #: src/screens/Signup/index.tsx:181
msgid "Step {0} of {1}" msgid "Step {0} of {1}"
msgstr "" msgstr ""
@@ -11290,11 +11292,11 @@ msgstr ""
msgid "Subscribe to account activity" msgid "Subscribe to account activity"
msgstr "" msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:347 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:350
msgid "Subscribe to Labeler" msgid "Subscribe to Labeler"
msgstr "" msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:313 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:316
msgid "Subscribe to this labeler" msgid "Subscribe to this labeler"
msgstr "" msgstr ""
@@ -11340,6 +11342,10 @@ msgstr ""
msgid "Suggestive" msgid "Suggestive"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:29
msgid "Sunlight"
msgstr "Sunlight"
#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72 #: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72
msgctxt "Name of app icon variant" msgctxt "Name of app icon variant"
msgid "Sunrise" msgid "Sunrise"
@@ -12299,8 +12305,8 @@ msgstr ""
msgid "Topic" msgid "Topic"
msgstr "" msgstr ""
#: src/components/dms/MessageContextMenu.tsx:176 #: src/components/dms/MessageContextMenu.tsx:191
#: src/components/dms/MessageContextMenu.tsx:179 #: src/components/dms/MessageContextMenu.tsx:194
#: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:150
#: src/components/Post/Translated/index.tsx:157 #: src/components/Post/Translated/index.tsx:157
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:557 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:557
@@ -12388,14 +12394,14 @@ msgstr "Try it"
msgid "TV" msgid "TV"
msgstr "" msgstr ""
#: src/features/inviteFriends/components/ThemePicker.tsx:32
msgid "Twilight"
msgstr "Twilight"
#: src/screens/Settings/PrivacyAndSecuritySettings.tsx:65 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:65
msgid "Two-factor authentication (2FA)" msgid "Two-factor authentication (2FA)"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageInput.tsx:201
msgid "Type your message here"
msgstr ""
#: src/screens/Settings/components/ChangeHandleDialog.tsx:445 #: src/screens/Settings/components/ChangeHandleDialog.tsx:445
msgid "Type:" msgid "Type:"
msgstr "" msgstr ""
@@ -12418,7 +12424,7 @@ msgstr ""
#: src/screens/Login/index.tsx:96 #: src/screens/Login/index.tsx:96
#: src/screens/Login/LoginForm.tsx:167 #: src/screens/Login/LoginForm.tsx:167
#: src/screens/Login/SetNewPasswordForm.tsx:83 #: src/screens/Login/SetNewPasswordForm.tsx:83
#: src/screens/Signup/index.tsx:87 #: src/screens/Signup/index.tsx:88
msgid "Unable to contact your service. Please check your Internet connection." msgid "Unable to contact your service. Please check your Internet connection."
msgstr "" msgstr ""
@@ -12677,7 +12683,7 @@ msgstr ""
msgid "Unsnooze email reminder" msgid "Unsnooze email reminder"
msgstr "" msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:345 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:348
msgid "Unsubscribe" msgid "Unsubscribe"
msgstr "" msgstr ""
@@ -12686,7 +12692,7 @@ msgstr ""
msgid "Unsubscribe from list" msgid "Unsubscribe from list"
msgstr "" msgstr ""
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:312 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:315
msgid "Unsubscribe from this labeler" msgid "Unsubscribe from this labeler"
msgstr "" msgstr ""
@@ -13455,7 +13461,7 @@ msgstr "Were having network issues, try again"
msgid "Were introducing a new layer of verification on Bluesky — an easy-to-see checkmark." msgid "Were introducing a new layer of verification on Bluesky — an easy-to-see checkmark."
msgstr "" msgstr ""
#: src/screens/Signup/index.tsx:136 #: src/screens/Signup/index.tsx:137
msgid "Were so excited to have you join us!" msgid "Were so excited to have you join us!"
msgstr "Were so excited to have you join us!" msgstr "Were so excited to have you join us!"
@@ -13559,7 +13565,7 @@ msgid "Who can verify?"
msgstr "" msgstr ""
#: src/screens/Home/NoFeedsPinned.tsx:80 #: src/screens/Home/NoFeedsPinned.tsx:80
#: src/screens/Messages/ChatList.tsx:393 #: src/screens/Messages/ChatList.tsx:407
#: src/screens/Messages/Inbox.tsx:198 #: src/screens/Messages/Inbox.tsx:198
msgid "Whoops!" msgid "Whoops!"
msgstr "" msgstr ""
@@ -13844,7 +13850,7 @@ msgid "You can update this later from your settings."
msgstr "" msgstr ""
#: src/components/dms/ActionsWrapper.web.tsx:81 #: src/components/dms/ActionsWrapper.web.tsx:81
#: src/components/dms/MessageContextMenu.tsx:115 #: src/components/dms/MessageContextMenu.tsx:129
msgid "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}" msgid "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}"
msgstr "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}" msgstr "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}"
@@ -14112,11 +14118,11 @@ msgstr "You wont be able to rejoin unless youre invited."
msgid "You: {message}" msgid "You: {message}"
msgstr "You: {message}" msgstr "You: {message}"
#: src/screens/Signup/index.tsx:152 #: src/screens/Signup/index.tsx:153
msgid "You'll follow the suggested users and feeds once you finish creating your account!" msgid "You'll follow the suggested users and feeds once you finish creating your account!"
msgstr "" msgstr ""
#: src/screens/Signup/index.tsx:157 #: src/screens/Signup/index.tsx:158
msgid "You'll follow the suggested users once you finish creating your account!" msgid "You'll follow the suggested users once you finish creating your account!"
msgstr "" msgstr ""
@@ -14204,7 +14210,7 @@ msgstr ""
msgid "You've run out of videos to watch. Maybe it's a good time to take a break?" msgid "You've run out of videos to watch. Maybe it's a good time to take a break?"
msgstr "" msgstr ""
#: src/screens/Signup/index.tsx:190 #: src/screens/Signup/index.tsx:191
msgid "Your account" msgid "Your account"
msgstr "" msgstr ""
+3 -10
View File
@@ -23,6 +23,7 @@ import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests' import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState' import {EmptyState} from '#/view/com/util/EmptyState'
@@ -84,16 +85,8 @@ export function MessagesInboxScreenInner({}: Props) {
return items return items
}, [data]) }, [data])
const hasUnreadConvos = useMemo(() => { const {data: unreadCounts} = useUnreadCountsQuery()
return conversations.some( const hasUnreadConvos = (unreadCounts?.unreadRequestConvos ?? 0) > 0
item =>
item.type === 'incoming' &&
item.view.members.every(
member => member.handle !== 'missing.invalid',
) &&
item.view.unreadCount > 0,
)
}, [conversations])
return ( return (
<Layout.Screen testID="messagesInboxScreen"> <Layout.Screen testID="messagesInboxScreen">
+103 -8
View File
@@ -1,13 +1,19 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {View} from 'react-native' import {Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {
type NotificationSettingsPreference,
useChatNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {ExportCarDialog} from '#/screens/Settings/components/ExportCarDialog' import {ExportCarDialog} from '#/screens/Settings/components/ExportCarDialog'
import {ChatNotificationDialogs} from '#/screens/Settings/NotificationSettings/components/ChatNotificationDialogs'
import {SettingPreview} from '#/screens/Settings/NotificationSettings/components/SettingPreview'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
@@ -18,7 +24,10 @@ import * as Toggle from '#/components/forms/Toggle'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
@@ -52,8 +61,12 @@ export function MessagesSettingsScreenInner({}: Props) {
const {data: profile} = useProfileQuery({ const {data: profile} = useProfileQuery({
did: currentAccount!.did, did: currentAccount!.did,
}) })
const {data: chatNotificationSettings, isError: chatSettingsError} =
useChatNotificationSettingsQuery()
const {preferences, setPref} = useBackgroundNotificationPreferences() const {preferences, setPref} = useBackgroundNotificationPreferences()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const exportCarControl = Dialog.useDialogControl() const exportCarControl = Dialog.useDialogControl()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -237,6 +250,54 @@ export function MessagesSettingsScreenInner({}: Props) {
<Divider style={{marginVertical: 10}} /> <Divider style={{marginVertical: 10}} />
</> </>
) : null} ) : null}
<View style={[a.px_xl, a.gap_lg]}>
<Text style={[a.pb_xs, a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>Notifications</Trans>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new messages`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatDialogControl.open()
}}>
<MessageIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New messages</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chat}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new message requests`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatRequestDialogControl.open()
}}>
<EnvelopeIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New message requests</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chatRequest}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
{IS_NATIVE && ( {IS_NATIVE && (
<> <>
<View style={[a.px_xl]}> <View style={[a.px_xl]}>
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</> </>
)} )}
<View style={[a.px_xl]}> <View style={[a.px_xl]}>
<Toggle.Item <Pressable
label={l`Export my chat data`} accessibilityRole="button"
name="playSoundChat" accessibilityLabel={l`Export my chat data`}
value={preferences.playSoundChat} accessibilityHint={undefined}
style={[a.flex_row, a.align_center, a.justify_between]} style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}
onChange={() => { onPress={() => {
exportCarControl.open() exportCarControl.open()
}}> }}>
<CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" /> <CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
<Trans>Export my chat data</Trans> <Trans>Export my chat data</Trans>
</Text> </Text>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" /> <ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Toggle.Item> </Pressable>
</View> </View>
<Divider style={{marginVertical: 10}} /> <Divider style={{marginVertical: 10}} />
</View> </View>
</Layout.Content> </Layout.Content>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<ExportCarDialog control={exportCarControl} /> <ExportCarDialog control={exportCarControl} />
</Layout.Screen> </Layout.Screen>
) )
} }
function NotificationPreferenceSubtitle({
preference,
isLoading,
isError,
}: {
preference?: NotificationSettingsPreference
isLoading: boolean
isError: boolean
}) {
const t = useTheme()
if (isError) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>Failed to load notification settings.</Trans>
</Text>
)
}
if (isLoading) {
return <Skele.Text style={[a.text_sm, {width: 120}]} />
}
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<SettingPreview preference={preference} />
</Text>
)
}
@@ -1,14 +1,12 @@
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {UNREAD_REQUEST_CAP} from '#/state/queries/messages/get-unread-counts'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button' import {ButtonIcon, ButtonText} from '#/components/Button'
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox' import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
// The server caps unreadRequestConvos at 11, where 11 means "any more than 10".
const REQUEST_COUNT_CAP = 11
export function InboxRequests({ export function InboxRequests({
count, count,
variant, variant,
@@ -21,7 +19,7 @@ export function InboxRequests({
const {t: l} = useLingui() const {t: l} = useLingui()
const unread = count > 0 const unread = count > 0
const overflow = count >= REQUEST_COUNT_CAP const overflow = count >= UNREAD_REQUEST_CAP
const label = !unread const label = !unread
? l({ ? l({
@@ -30,8 +28,8 @@ export function InboxRequests({
}) })
: overflow : overflow
? l({ ? l({
message: `10+ requests`, message: `${UNREAD_REQUEST_CAP - 1}+ requests`,
comment: 'Displayed when the number of requests is greater than 10', comment: 'Displayed when the number of requests exceeds the cap',
}) })
: plural(count, { : plural(count, {
one: '# request', one: '# request',
@@ -55,9 +53,9 @@ export function InboxRequests({
<ButtonText style={[a.text_md, a.font_bold]}> <ButtonText style={[a.text_md, a.font_bold]}>
{overflow {overflow
? l({ ? l({
message: `10+`, message: `${UNREAD_REQUEST_CAP - 1}+`,
comment: comment:
'Displayed when the number of requests is greater than 10', 'Displayed when the number of requests exceeds the cap for example, 99+ requests',
}) })
: count} : count}
</ButtonText> </ButtonText>
@@ -1,5 +1,5 @@
import {LayoutAnimation, View} from 'react-native' import {LayoutAnimation, View} from 'react-native'
import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api' import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants' import {HITSLOP_20} from '#/lib/constants'
@@ -8,6 +8,7 @@ import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useMessageReplies} from '#/components/dms/MessageReplies' import {useMessageReplies} from '#/components/dms/MessageReplies'
import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -16,15 +17,27 @@ import {Text} from '#/components/Typography'
* being replied to, with a button to cancel the reply. * being replied to, with a button to cancel the reply.
*/ */
export function MessageInputReply() { export function MessageInputReply() {
const t = useTheme() const {replyTo} = useMessageReplies()
const {t: l} = useLingui()
const convo = useConvoActive()
const {replyTo, clearReply} = useMessageReplies()
if (!replyTo) { if (!replyTo) {
return null return null
} }
return <MessageInputReplyInner replyTo={replyTo} />
}
function MessageInputReplyInner({
replyTo,
}: {
replyTo: ChatBskyConvoDefs.MessageView
}) {
const t = useTheme()
const {t: l} = useLingui()
const convo = useConvoActive()
const {clearReply} = useMessageReplies()
const getReplyPreviewText = useReplyPreviewText()
const {text, subtle} = getReplyPreviewText(replyTo)
const onRemove = () => { const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
clearReply() clearReply()
@@ -35,19 +48,6 @@ export function MessageInputReply() {
? createSanitizedDisplayName(senderProfile, false) ? createSanitizedDisplayName(senderProfile, false)
: null : null
let text = replyTo.text
let subtle = false
if (!text.trim()) {
subtle = true
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
text = l`(chat invite link)`
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
text = l`(contains embedded content)`
} else {
text = l`No text`
}
}
return ( return (
<View <View
style={[ style={[
@@ -82,7 +82,7 @@ export function MessageInputReply() {
<Button <Button
label={l`Cancel reply`} label={l`Cancel reply`}
onPress={onRemove} onPress={onRemove}
style={[a.px_2xs]} style={[a.px_2xs, {transform: [{translateX: 2}]}]}
hitSlop={HITSLOP_20}> hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} /> <XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button> </Button>
@@ -0,0 +1,41 @@
import {Trans} from '@lingui/react/macro'
import type * as Dialog from '#/components/Dialog'
import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSettingsDialog'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
export function ChatNotificationDialogs({
chatControl,
chatRequestControl,
}: {
chatControl: Dialog.DialogControlProps
chatRequestControl: Dialog.DialogControlProps
}) {
return (
<>
<NotificationSettingsDialog
control={chatControl}
name="chat"
icon={MessageIcon}
titleText={<Trans>New messages</Trans>}
subtitleText={
<Trans>Get notifications when people send you messages.</Trans>
}
allowDisableInApp={false}
/>
<NotificationSettingsDialog
control={chatRequestControl}
name="chatRequest"
icon={EnvelopeIcon}
titleText={<Trans>New message requests</Trans>}
subtitleText={
<Trans>
Get notifications when people send you message requests.
</Trans>
}
allowDisableInApp={false}
/>
</>
)
}
@@ -1,9 +1,12 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' import {
type NotificationSettingsPreference,
type NotificationSettingsPreferenceName,
useNotificationSettingsUpdateMutation,
} from '#/state/queries/notifications/settings'
import {atoms as a, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
@@ -17,16 +20,13 @@ export function PreferenceControls({
preference, preference,
allowDisableInApp = true, allowDisableInApp = true,
}: { }: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> name: NotificationSettingsPreferenceName
/** /**
* Keep other prefs in sync with `name`. For use in the "everything else" category * Keep other prefs in sync with `name`. For use in the "everything else" category
* which groups starterpack joins + verified + unverified notifications into a single toggle. * which groups starterpack joins + verified + unverified notifications into a single toggle.
*/ */
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] syncOthers?: NotificationSettingsPreferenceName[]
preference?: preference?: NotificationSettingsPreference
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
allowDisableInApp?: boolean allowDisableInApp?: boolean
}) { }) {
if (!preference) if (!preference)
@@ -52,12 +52,9 @@ export function Inner({
preference, preference,
allowDisableInApp, allowDisableInApp,
}: { }: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'> name: NotificationSettingsPreferenceName
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[] syncOthers?: NotificationSettingsPreferenceName[]
preference: preference: NotificationSettingsPreference
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
allowDisableInApp: boolean allowDisableInApp: boolean
}) { }) {
const t = useTheme() const t = useTheme()
@@ -95,7 +92,7 @@ export function Inner({
} }
const onChangeFilter = ([change]: string[]) => { const onChangeFilter = ([change]: string[]) => {
if (change !== 'all' && change !== 'follows' && change !== 'accepted') if (change !== 'all' && change !== 'follows')
throw new Error('Invalid filter') throw new Error('Invalid filter')
const newPreference = { const newPreference = {
@@ -135,7 +132,7 @@ export function Inner({
</Toggle.LabelText> </Toggle.LabelText>
<Toggle.Platform /> <Toggle.Platform />
</Toggle.Item> </Toggle.Item>
{allowDisableInApp && ( {allowDisableInApp && 'list' in preference && (
<Toggle.Item <Toggle.Item
label={l`Receive in-app notifications`} label={l`Receive in-app notifications`}
name="list" name="list"
@@ -176,19 +173,6 @@ export function Inner({
/> />
)} )}
</Toggle.Item> </Toggle.Item>
{name === 'chat' ? (
<Toggle.Item
highlightRow
label={l`Accepted conversations`}
name="accepted">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`Accepted conversations`}
selected={selected}
/>
)}
</Toggle.Item>
) : (
<Toggle.Item <Toggle.Item
highlightRow highlightRow
label={l`People I follow`} label={l`People I follow`}
@@ -200,7 +184,6 @@ export function Inner({
/> />
)} )}
</Toggle.Item> </Toggle.Item>
)}
</View> </View>
</Toggle.Group> </Toggle.Group>
</> </>
@@ -0,0 +1,35 @@
import {useLingui} from '@lingui/react/macro'
import {type NotificationSettingsPreference} from '#/state/queries/notifications/settings'
export function SettingPreview({
preference,
}: {
preference?: NotificationSettingsPreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
}
if ('include' in preference) {
const list = 'list' in preference && preference.list
if (preference.include === 'all') {
if (list && preference.push) return l`In-app, push, everyone`
if (list) return l`In-app, everyone`
if (preference.push) return l`Push, everyone`
} else if (preference.include === 'follows') {
if (list && preference.push) return l`In-app, push, people you follow`
if (list) return l`In-app, people you follow`
if (preference.push) return l`Push, people you follow`
}
} else {
if (preference.list && preference.push) return l`In-app, push`
if (preference.list) return l`In-app`
if (preference.push) return l`Push`
}
return l`Off`
}
@@ -1,7 +1,6 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import {Linking, View} from 'react-native' import {Linking, View} from 'react-native'
import * as Notification from 'expo-notifications' import * as Notification from 'expo-notifications'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery, useQueryClient} from '@tanstack/react-query'
@@ -10,7 +9,10 @@ import {
type AllNavigatorParams, type AllNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' import {
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -18,11 +20,13 @@ import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSetti
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
import { import {
Heart2_Stroke2_Corner0_Rounded as HeartIcon, Heart2_Stroke2_Corner0_Rounded as HeartIcon,
LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon, LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
} from '#/components/icons/Heart2' } from '#/components/icons/Heart2'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
import { import {
@@ -33,7 +37,9 @@ import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/S
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import * as SettingsList from '../components/SettingsList' import * as SettingsList from '../components/SettingsList'
import {ChatNotificationDialogs} from './components/ChatNotificationDialogs'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {SettingPreview} from './components/SettingPreview'
const RQKEY = ['notification-permissions'] const RQKEY = ['notification-permissions']
@@ -42,6 +48,8 @@ export function NotificationSettingsScreen({}: Props) {
const {t: l} = useLingui() const {t: l} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {data: settings, isError} = useNotificationSettingsQuery() const {data: settings, isError} = useNotificationSettingsQuery()
const {data: chatSettings, isError: chatError} =
useChatNotificationSettingsQuery()
const likeDialogControl = Dialog.useDialogControl() const likeDialogControl = Dialog.useDialogControl()
const followDialogControl = Dialog.useDialogControl() const followDialogControl = Dialog.useDialogControl()
@@ -52,6 +60,8 @@ export function NotificationSettingsScreen({}: Props) {
const activityDialogControl = Dialog.useDialogControl() const activityDialogControl = Dialog.useDialogControl()
const likeRepostDialogControl = Dialog.useDialogControl() const likeRepostDialogControl = Dialog.useDialogControl()
const repostRepostDialogControl = Dialog.useDialogControl() const repostRepostDialogControl = Dialog.useDialogControl()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const miscDialogControl = Dialog.useDialogControl() const miscDialogControl = Dialog.useDialogControl()
const {data: permissions, refetch} = useQuery({ const {data: permissions, refetch} = useQuery({
@@ -234,6 +244,40 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings} showSkeleton={!settings}
/> />
</SettingsList.PressableItem> </SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new messages`}
onPress={chatDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={MessageIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New messages</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chat} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new message requests`}
onPress={chatRequestDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={EnvelopeIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New message requests</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chatRequest} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem <SettingsList.PressableItem
label={l`Settings for notifications for everything else`} label={l`Settings for notifications for everything else`}
onPress={miscDialogControl.open} onPress={miscDialogControl.open}
@@ -333,6 +377,10 @@ export function NotificationSettingsScreen({}: Props) {
<Trans>Get notifications when people repost your reposts.</Trans> <Trans>Get notifications when people repost your reposts.</Trans>
} }
/> />
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<NotificationSettingsDialog <NotificationSettingsDialog
control={miscDialogControl} control={miscDialogControl}
name="starterpackJoined" name="starterpackJoined"
@@ -350,46 +398,3 @@ export function NotificationSettingsScreen({}: Props) {
</Layout.Screen> </Layout.Screen>
) )
} }
function SettingPreview({
preference,
}: {
preference?:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
} else {
if ('include' in preference) {
if (preference.include === 'all') {
if (preference.list && preference.push) {
return l`In-app, push, everyone`
} else if (preference.list) {
return l`In-app, everyone`
} else if (preference.push) {
return l`Push, everyone`
}
} else if (preference.include === 'follows') {
if (preference.list && preference.push) {
return l`In-app, push, people you follow`
} else if (preference.list) {
return l`In-app, people you follow`
} else if (preference.push) {
return l`Push, people you follow`
}
}
} else {
if (preference.list && preference.push) {
return l`In-app, push`
} else if (preference.list) {
return l`In-app`
} else if (preference.push) {
return l`Push`
}
}
}
return l`Off`
}
@@ -10,13 +10,12 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const [RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31 // the server sentinel-caps the badge counts: unreadAcceptedConvos and
// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than // unreadRequestConvos max out at 100 (meaning "more than 99"). at the cap the
// 10"). at the cap the value is no longer an exact count, so consumers must not // value is no longer an exact count, so consumers must not treat it as one -
// treat it as one - both the optimistic decrement and the badge display ceiling // both the optimistic decrement and the badge display ceiling key off these.
// key off these. export const UNREAD_ACCEPTED_CAP = 100
export const UNREAD_ACCEPTED_CAP = 31 export const UNREAD_REQUEST_CAP = 100
export const UNREAD_REQUEST_CAP = 11
export function useUnreadCountsQuery() { export function useUnreadCountsQuery() {
const agent = useAgent() const agent = useAgent()
@@ -856,18 +856,16 @@ export function useUnreadMessageCount(): {
const request = data?.unreadRequestConvos ?? 0 const request = data?.unreadRequestConvos ?? 0
if (accepted > 0) { if (accepted > 0) {
const total = accepted + Math.min(request, 1)
return { return {
count: total, count: accepted,
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than // accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
// cap - 1"). show the "+" overflow label only when accepted is actually // cap - 1"). show the "+" overflow label only when accepted is actually
// capped - the +1 request nudge must not trip it at exactly cap - 1 // capped, otherwise clamp the number to cap - 1 so we never surface the
// accepted convos. otherwise clamp the number to cap - 1 so the nudge // sentinel value (100) itself
// never surfaces the sentinel value (31) itself
numUnread: numUnread:
accepted >= UNREAD_ACCEPTED_CAP accepted >= UNREAD_ACCEPTED_CAP
? `${UNREAD_ACCEPTED_CAP - 1}+` ? `${UNREAD_ACCEPTED_CAP - 1}+`
: String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)), : String(Math.min(accepted, UNREAD_ACCEPTED_CAP - 1)),
// only needed when numUnread is undefined // only needed when numUnread is undefined
hasNew: false, hasNew: false,
} }
+141 -17
View File
@@ -1,4 +1,7 @@
import {type AppBskyNotificationDefs} from '@atproto/api' import {
type AppBskyNotificationDefs,
type ChatBskyNotificationDefs,
} from '@atproto/api'
import {t} from '@lingui/core/macro' import {t} from '@lingui/core/macro'
import { import {
type QueryClient, type QueryClient,
@@ -7,12 +10,59 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
const RQKEY_ROOT = 'notification-settings' const RQKEY_ROOT = 'notification-settings'
const RQKEY = [RQKEY_ROOT] const RQKEY_APP = [RQKEY_ROOT, 'app']
const RQKEY_CHAT = [RQKEY_ROOT, 'chat']
// App notification preferences live on the appview. Chat preferences live on a
// separate chat service proxy that can be up or down independently, so they are
// fetched and cached separately. This combined type names every preference for
// the generic settings dialog, but it is never the shape of a query response.
export type NotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
> &
Partial<Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'>>
export type AppNotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
>
export type ChatNotificationSettingsPreferences = Pick<
ChatBskyNotificationDefs.Preferences,
'chat' | 'chatRequest'
>
export type NotificationSettingsPreferenceName = Exclude<
keyof NotificationSettingsPreferences,
'$type'
>
export type NotificationSettingsPreference =
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| ChatBskyNotificationDefs.ChatPreference
export function isChatPreferenceName(
name: NotificationSettingsPreferenceName,
): name is 'chat' | 'chatRequest' {
return name === 'chat' || name === 'chatRequest'
}
type NotificationSettingsUpdate = Partial<NotificationSettingsPreferences>
type AppNotificationSettingsUpdate = Partial<
Omit<AppBskyNotificationDefs.Preferences, '$type' | 'chat'>
>
type ChatNotificationSettingsUpdate =
Partial<ChatNotificationSettingsPreferences>
export function useNotificationSettingsQuery({ export function useNotificationSettingsQuery({
enabled, enabled,
@@ -20,10 +70,27 @@ export function useNotificationSettingsQuery({
const agent = useAgent() const agent = useAgent()
return useQuery({ return useQuery({
queryKey: RQKEY, queryKey: RQKEY_APP,
queryFn: async () => { queryFn: async (): Promise<AppNotificationSettingsPreferences> => {
const response = await agent.app.bsky.notification.getPreferences() const res = await agent.app.bsky.notification.getPreferences()
return response.data.preferences return appPreferencesWithoutChat(res.data.preferences)
},
enabled,
})
}
export function useChatNotificationSettingsQuery({
enabled,
}: {enabled?: boolean} = {}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY_CHAT,
queryFn: async (): Promise<ChatNotificationSettingsPreferences> => {
const res = await agent.chat.bsky.notification.getPreferences(undefined, {
headers: DM_SERVICE_HEADERS,
})
return chatPreferencesForSettings(res.data.preferences)
}, },
enabled, enabled,
}) })
@@ -33,19 +100,27 @@ export function useNotificationSettingsUpdateMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ( mutationFn: async (update: NotificationSettingsUpdate) => {
update: Partial<AppBskyNotificationDefs.Preferences>, const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
) => { await Promise.all([
const response = hasUpdates(appUpdate)
await agent.app.bsky.notification.putPreferencesV2(update) ? agent.app.bsky.notification.putPreferencesV2(appUpdate)
return response.data.preferences : undefined,
hasUpdates(chatUpdate)
? agent.chat.bsky.notification.putPreferences(chatUpdate, {
headers: DM_SERVICE_HEADERS,
encoding: 'application/json',
})
: undefined,
])
}, },
onMutate: update => { onMutate: update => {
optimisticUpdateNotificationSettings(queryClient, update) optimisticUpdateNotificationSettings(queryClient, update)
}, },
onError: e => { onError: e => {
logger.error('Could not update notification settings', {message: e}) logger.error('Could not update notification settings', {message: e})
void queryClient.invalidateQueries({queryKey: RQKEY}) void queryClient.invalidateQueries({queryKey: RQKEY_APP})
void queryClient.invalidateQueries({queryKey: RQKEY_CHAT})
Toast.show(t`Could not update notification settings`, { Toast.show(t`Could not update notification settings`, {
type: 'error', type: 'error',
}) })
@@ -55,13 +130,62 @@ export function useNotificationSettingsUpdateMutation() {
function optimisticUpdateNotificationSettings( function optimisticUpdateNotificationSettings(
queryClient: QueryClient, queryClient: QueryClient,
update: Partial<AppBskyNotificationDefs.Preferences>, update: NotificationSettingsUpdate,
) { ) {
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
if (hasUpdates(appUpdate)) {
queryClient.setQueryData( queryClient.setQueryData(
RQKEY, RQKEY_APP,
(old?: AppBskyNotificationDefs.Preferences) => { (old?: AppNotificationSettingsPreferences) => {
if (!old) return old if (!old) return old
return {...old, ...update} return {...old, ...appUpdate}
}, },
) )
} }
if (hasUpdates(chatUpdate)) {
queryClient.setQueryData(
RQKEY_CHAT,
(old?: ChatNotificationSettingsPreferences) => {
if (!old) return old
return {...old, ...chatUpdate}
},
)
}
}
function appPreferencesWithoutChat(
preferences: AppBskyNotificationDefs.Preferences,
): Omit<AppBskyNotificationDefs.Preferences, 'chat'> {
const {chat: _ignoredChat, ...appPreferences} = preferences
return appPreferences
}
function chatPreferencesForSettings(
preferences: ChatBskyNotificationDefs.Preferences,
): Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'> {
return {
chat: preferences.chat,
chatRequest: preferences.chatRequest,
}
}
function splitNotificationSettingsUpdate(update: NotificationSettingsUpdate): {
appUpdate: AppNotificationSettingsUpdate
chatUpdate: ChatNotificationSettingsUpdate
} {
const {chat, chatRequest, $type: _type, ...appUpdate} = update
return {
appUpdate: appUpdate,
chatUpdate: {
...(chat !== undefined ? {chat} : {}),
...(chatRequest !== undefined ? {chatRequest} : {}),
},
}
}
function hasUpdates(update: object) {
return Object.keys(update).length > 0
}
+26 -2
View File
@@ -73,6 +73,7 @@ import {
} from '#/lib/constants' } from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once' import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
@@ -387,6 +388,12 @@ export const ComposePost = ({
const selectVideo = useCallback( const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => { (postId: string, asset: ImagePickerAsset) => {
const abortController = new AbortController() const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId: postId, postId: postId,
@@ -394,6 +401,7 @@ export const ComposePost = ({
type: 'embed_add_video', type: 'embed_add_video',
asset, asset,
abortController, abortController,
telemetry,
}, },
}) })
void processVideo( void processVideo(
@@ -412,7 +420,7 @@ export const ComposePost = ({
currentDid, currentDid,
abortController.signal, abortController.signal,
i18n, i18n,
ax.metric, telemetry,
) )
}, },
[i18n, agent, currentDid, composerDispatch, ax.metric], [i18n, agent, currentDid, composerDispatch, ax.metric],
@@ -495,6 +503,12 @@ export const ComposePost = ({
// Start video processing using existing flow // Start video processing using existing flow
const abortController = new AbortController() const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({ composerDispatch({
type: 'update_post', type: 'update_post',
postId, postId,
@@ -502,6 +516,7 @@ export const ComposePost = ({
type: 'embed_add_video', type: 'embed_add_video',
asset, asset,
abortController, abortController,
telemetry,
}, },
}) })
@@ -560,7 +575,7 @@ export const ComposePost = ({
currentDid, currentDid,
abortController.signal, abortController.signal,
i18n, i18n,
ax.metric, telemetry,
) )
} catch (e) { } catch (e) {
logger.error('Failed to restore video from draft', { logger.error('Failed to restore video from draft', {
@@ -981,6 +996,15 @@ export const ComposePost = ({
}) })
).uris[0] ).uris[0]
// Fire published event for every video that made it into the post.
// The status guard upstream ensures each video.telemetry is present and
// processing has completed by this point.
for (const post of filteredThread.posts) {
if (post.embed.media?.type === 'video') {
post.embed.media.video.telemetry?.published()
}
}
/* /*
* Wait for app view to have received the post(s). If this fails, it's * Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above. * ok, because the post _was_ actually published above.
+7 -1
View File
@@ -8,6 +8,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type SelfLabel} from '#/lib/moderation' import {type SelfLabel} from '#/lib/moderation'
import {insertMentionAt} from '#/lib/strings/mention-manip' import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip'
@@ -88,6 +89,7 @@ export type PostAction =
type: 'embed_add_video' type: 'embed_add_video'
asset: ImagePickerAsset asset: ImagePickerAsset
abortController: AbortController abortController: AbortController
telemetry: VideoTelemetry
} }
| {type: 'embed_remove_video'} | {type: 'embed_remove_video'}
| {type: 'embed_update_video'; videoAction: VideoAction} | {type: 'embed_update_video'; videoAction: VideoAction}
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
if (!prevMedia) { if (!prevMedia) {
nextMedia = { nextMedia = {
type: 'video', type: 'video',
video: createVideoState(action.asset, action.abortController), video: createVideoState(
action.asset,
action.abortController,
action.telemetry,
),
} }
} }
return { return {
+31 -23
View File
@@ -11,12 +11,12 @@ import {
UploadLimitError, UploadLimitError,
VideoTooLargeError, VideoTooLargeError,
} from '#/lib/media/video/errors' } from '#/lib/media/video/errors'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types' import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload' import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util' import {createVideoAgent} from '#/lib/media/video/util'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type AnalyticsContextType} from '#/analytics'
type CaptionsTrack = {lang: string; file: File} type CaptionsTrack = {lang: string; file: File}
@@ -65,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
video: undefined, video: undefined,
jobId: undefined, jobId: undefined,
pendingPublish: undefined, pendingPublish: undefined,
telemetry: undefined,
altText: '', altText: '',
captions: [], captions: [],
}) })
@@ -80,6 +81,7 @@ type ErrorState = {
jobId: string | null jobId: string | null
error: string error: string
pendingPublish?: undefined pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string altText: string
captions: CaptionsTrack[] captions: CaptionsTrack[]
} }
@@ -92,6 +94,7 @@ type CompressingState = {
video?: undefined video?: undefined
jobId?: undefined jobId?: undefined
pendingPublish?: undefined pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string altText: string
captions: CaptionsTrack[] captions: CaptionsTrack[]
} }
@@ -104,6 +107,7 @@ type UploadingState = {
video: CompressedVideo video: CompressedVideo
jobId?: undefined jobId?: undefined
pendingPublish?: undefined pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string altText: string
captions: CaptionsTrack[] captions: CaptionsTrack[]
} }
@@ -117,6 +121,7 @@ type ProcessingState = {
jobId: string jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string altText: string
captions: CaptionsTrack[] captions: CaptionsTrack[]
} }
@@ -129,6 +134,7 @@ type DoneState = {
video: CompressedVideo video: CompressedVideo
jobId?: undefined jobId?: undefined
pendingPublish: {blobRef: BlobRef} pendingPublish: {blobRef: BlobRef}
telemetry: VideoTelemetry
altText: string altText: string
captions: CaptionsTrack[] captions: CaptionsTrack[]
} }
@@ -143,12 +149,14 @@ export type VideoState =
export function createVideoState( export function createVideoState(
asset: ImagePickerAsset, asset: ImagePickerAsset,
abortController: AbortController, abortController: AbortController,
telemetry: VideoTelemetry,
): CompressingState { ): CompressingState {
return { return {
status: 'compressing', status: 'compressing',
progress: 0, progress: 0,
abortController, abortController,
asset, asset,
telemetry,
altText: '', altText: '',
captions: [], captions: [],
} }
@@ -171,6 +179,7 @@ export function videoReducer(
asset: state.asset ?? null, asset: state.asset ?? null,
video: state.video ?? null, video: state.video ?? null,
jobId: state.jobId ?? null, jobId: state.jobId ?? null,
telemetry: state.telemetry,
altText: state.altText, altText: state.altText,
captions: state.captions, captions: state.captions,
} }
@@ -199,6 +208,7 @@ export function videoReducer(
abortController: state.abortController, abortController: state.abortController,
asset: state.asset, asset: state.asset,
video: action.video, video: action.video,
telemetry: state.telemetry,
altText: state.altText, altText: state.altText,
captions: state.captions, captions: state.captions,
} }
@@ -214,6 +224,7 @@ export function videoReducer(
video: state.video, video: state.video,
jobId: action.jobId, jobId: action.jobId,
jobStatus: null, jobStatus: null,
telemetry: state.telemetry,
altText: state.altText, altText: state.altText,
captions: state.captions, captions: state.captions,
} }
@@ -240,6 +251,7 @@ export function videoReducer(
pendingPublish: { pendingPublish: {
blobRef: action.blobRef, blobRef: action.blobRef,
}, },
telemetry: state.telemetry,
altText: state.altText, altText: state.altText,
captions: state.captions, captions: state.captions,
} }
@@ -266,40 +278,21 @@ export async function processVideo(
did: string, did: string,
signal: AbortSignal, signal: AbortSignal,
i18n: I18n, i18n: I18n,
metric: AnalyticsContextType['metric'], telemetry: VideoTelemetry,
) { ) {
let video: CompressedVideo | undefined let video: CompressedVideo | undefined
try { try {
telemetry.compressStarted()
video = await compressVideo(asset, { video = await compressVideo(asset, {
onProgress: num => { onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
}, },
signal, signal,
onProbe: (metadata, wouldCompress) => {
metric('composer:video:probe', {
mimeType: metadata.mimeType,
codec: metadata.codec,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
bitrate: metadata.bitrate,
fileSize: metadata.fileSize,
hasAudio: metadata.hasAudio,
frameRate: metadata.frameRate,
rotation: metadata.rotation,
isHDR: metadata.isHDR,
wouldCompress,
})
},
onProbeFailed: e => {
metric('composer:video:probeFailed', {
safeMessage: e instanceof Error ? e.message : String(e),
})
},
}) })
} catch (e) { } catch (e) {
const message = getCompressErrorMessage(e, i18n) const message = getCompressErrorMessage(e, i18n)
if (message !== null) { if (message !== null) {
telemetry.compressFailed(e)
dispatch({ dispatch({
type: 'to_error', type: 'to_error',
error: message, error: message,
@@ -308,6 +301,15 @@ export async function processVideo(
} }
return return
} }
if (video.passthroughReason) {
telemetry.compressSkipped({
size: video.size,
mimeType: video.mimeType,
skipReason: video.passthroughReason,
})
} else {
telemetry.compressCompleted({size: video.size, mimeType: video.mimeType})
}
dispatch({ dispatch({
type: 'compressing_to_uploading', type: 'compressing_to_uploading',
video, video,
@@ -316,6 +318,7 @@ export async function processVideo(
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try { try {
telemetry.uploadStarted(video.size)
uploadResponse = await uploadVideo({ uploadResponse = await uploadVideo({
video, video,
agent, agent,
@@ -329,6 +332,7 @@ export async function processVideo(
} catch (e) { } catch (e) {
const message = getUploadErrorMessage(e, i18n) const message = getUploadErrorMessage(e, i18n)
if (message !== null) { if (message !== null) {
telemetry.uploadFailed(e)
dispatch({ dispatch({
type: 'to_error', type: 'to_error',
error: message, error: message,
@@ -339,6 +343,8 @@ export async function processVideo(
} }
const jobId = uploadResponse.jobId const jobId = uploadResponse.jobId
telemetry.uploadCompleted(jobId)
telemetry.processingStarted(jobId)
dispatch({ dispatch({
type: 'uploading_to_processing', type: 'uploading_to_processing',
jobId, jobId,
@@ -377,6 +383,7 @@ export async function processVideo(
} }
logger.error('Error processing video', {safeMessage: e}) logger.error('Error processing video', {safeMessage: e})
telemetry.processingFailed(e)
dispatch({ dispatch({
type: 'to_error', type: 'to_error',
error: i18n._(msg`Video failed to process`), error: i18n._(msg`Video failed to process`),
@@ -386,6 +393,7 @@ export async function processVideo(
} }
if (blob) { if (blob) {
telemetry.processingCompleted()
dispatch({ dispatch({
type: 'to_done', type: 'to_done',
blobRef: blob, blobRef: blob,
+6
View File
@@ -242,7 +242,13 @@ export function BottomBar({navigation}: BottomTabBarProps) {
accessibilityLabel={l`Chat`} accessibilityLabel={l`Chat`}
accessibilityHint={ accessibilityHint={
!aa.flags.chatDisabled && numUnreadMessages.count > 0 !aa.flags.chatDisabled && numUnreadMessages.count > 0
? numUnreadMessages.numUnread?.includes('+')
? l({ ? l({
message: `${numUnreadMessages.numUnread} unread items`,
comment:
'Accessibility hint for the bottom bar chat icon when the number of unread messages exceeds the cap, with the + symbol already included for example, 99+ unread items',
})
: l({
message: plural(numUnreadMessages.numUnread ?? 0, { message: plural(numUnreadMessages.numUnread ?? 0, {
one: '# unread item', one: '# unread item',
other: '# unread items', other: '# unread items',
+5
View File
@@ -27,6 +27,10 @@ export function DesktopSearch() {
if (query.length) setActive(true) if (query.length) setActive(true)
} }
const onBlur = () => {
setActive(false)
}
const onChangeText = (text: string) => { const onChangeText = (text: string) => {
setQuery(text) setQuery(text)
if (!active) { if (!active) {
@@ -64,6 +68,7 @@ export function DesktopSearch() {
hotkey hotkey
value={query} value={query}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur}
onChangeText={onChangeText} onChangeText={onChangeText}
onClearText={onClearText} onClearText={onClearText}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}