diff --git a/Dockerfile.embedr b/Dockerfile.embedr
index bf559ec583..203149115f 100644
--- a/Dockerfile.embedr
+++ b/Dockerfile.embedr
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
- npm install --global pnpm@11.7.0 && \
+ npm install --global pnpm@11.9.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index c3bbd462ca..06ac176613 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -946,14 +946,6 @@
"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": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2183,14 +2175,6 @@
"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": {
"@typescript-eslint/no-explicit-any": {
"count": 2
diff --git a/package.json b/package.json
index ccd213d743..bb0c86960a 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
- "version": "11.7.0",
+ "version": "11.9.0",
"onFail": "warn"
},
"runtime": {
@@ -94,7 +94,7 @@
"update-actions": "pnpm dlx actions-up --min-age 7"
},
"dependencies": {
- "@atproto/api": "0.20.15",
+ "@atproto/api": "0.20.20",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eee11bf2c3..d1ff05dc50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
- specifier: 11.7.0
- version: 11.7.0
+ specifier: 11.9.0
+ version: 11.9.0
pnpm:
- specifier: 11.7.0
- version: 11.7.0
+ specifier: 11.9.0
+ version: 11.9.0
packages:
- '@pnpm/exe@11.7.0':
- resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==}
+ '@pnpm/exe@11.9.0':
+ resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
hasBin: true
- '@pnpm/linux-arm64@11.7.0':
- resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==}
+ '@pnpm/linux-arm64@11.9.0':
+ resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
cpu: [arm64]
os: [linux]
- '@pnpm/linux-x64@11.7.0':
- resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==}
+ '@pnpm/linux-x64@11.9.0':
+ resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
cpu: [x64]
os: [linux]
- '@pnpm/linuxstatic-arm64@11.7.0':
- resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==}
+ '@pnpm/linuxstatic-arm64@11.9.0':
+ resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/linuxstatic-x64@11.7.0':
- resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==}
+ '@pnpm/linuxstatic-x64@11.9.0':
+ resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/macos-arm64@11.7.0':
- resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==}
+ '@pnpm/macos-arm64@11.9.0':
+ resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
cpu: [arm64]
os: [darwin]
- '@pnpm/win-arm64@11.7.0':
- resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==}
+ '@pnpm/win-arm64@11.9.0':
+ resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
cpu: [arm64]
os: [win32]
- '@pnpm/win-x64@11.7.0':
- resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==}
+ '@pnpm/win-x64@11.9.0':
+ resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
- pnpm@11.7.0:
- resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==}
+ pnpm@11.9.0:
+ resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
- '@pnpm/exe@11.7.0':
+ '@pnpm/exe@11.9.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
- '@pnpm/linux-arm64': 11.7.0
- '@pnpm/linux-x64': 11.7.0
- '@pnpm/linuxstatic-arm64': 11.7.0
- '@pnpm/linuxstatic-x64': 11.7.0
- '@pnpm/macos-arm64': 11.7.0
- '@pnpm/win-arm64': 11.7.0
- '@pnpm/win-x64': 11.7.0
+ '@pnpm/linux-arm64': 11.9.0
+ '@pnpm/linux-x64': 11.9.0
+ '@pnpm/linuxstatic-arm64': 11.9.0
+ '@pnpm/linuxstatic-x64': 11.9.0
+ '@pnpm/macos-arm64': 11.9.0
+ '@pnpm/win-arm64': 11.9.0
+ '@pnpm/win-x64': 11.9.0
- '@pnpm/linux-arm64@11.7.0':
+ '@pnpm/linux-arm64@11.9.0':
optional: true
- '@pnpm/linux-x64@11.7.0':
+ '@pnpm/linux-x64@11.9.0':
optional: true
- '@pnpm/linuxstatic-arm64@11.7.0':
+ '@pnpm/linuxstatic-arm64@11.9.0':
optional: true
- '@pnpm/linuxstatic-x64@11.7.0':
+ '@pnpm/linuxstatic-x64@11.9.0':
optional: true
- '@pnpm/macos-arm64@11.7.0':
+ '@pnpm/macos-arm64@11.9.0':
optional: true
- '@pnpm/win-arm64@11.7.0':
+ '@pnpm/win-arm64@11.9.0':
optional: true
- '@pnpm/win-x64@11.7.0':
+ '@pnpm/win-x64@11.9.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
- pnpm@11.7.0: {}
+ pnpm@11.9.0: {}
---
lockfileVersion: '9.0'
@@ -241,8 +241,8 @@ importers:
.:
dependencies:
'@atproto/api':
- specifier: 0.20.15
- version: 0.20.15
+ specifier: 0.20.20
+ version: 0.20.20
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -876,32 +876,36 @@ packages:
graphql:
optional: true
- '@atproto/api@0.20.15':
- resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
+ '@atproto/api@0.20.20':
+ resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
engines: {node: '>=22'}
- '@atproto/common-web@0.5.0':
- resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
+ '@atproto/common-web@0.5.1':
+ resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
engines: {node: '>=22'}
- '@atproto/lex-data@0.1.1':
- resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
+ '@atproto/lex-data@0.1.2':
+ resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
engines: {node: '>=22'}
- '@atproto/lex-json@0.1.0':
- resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
+ '@atproto/lex-json@0.1.1':
+ resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
engines: {node: '>=22'}
- '@atproto/lexicon@0.7.1':
- resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
+ '@atproto/lexicon@0.7.2':
+ resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
- '@atproto/xrpc@0.8.0':
- resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
+ '@atproto/syntax@0.6.2':
+ resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
+ engines: {node: '>=22'}
+
+ '@atproto/xrpc@0.8.1':
+ resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -9472,40 +9476,40 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
- '@atproto/api@0.20.15':
+ '@atproto/api@0.20.20':
dependencies:
- '@atproto/common-web': 0.5.0
- '@atproto/lexicon': 0.7.1
- '@atproto/syntax': 0.6.1
- '@atproto/xrpc': 0.8.0
+ '@atproto/common-web': 0.5.1
+ '@atproto/lexicon': 0.7.2
+ '@atproto/syntax': 0.6.2
+ '@atproto/xrpc': 0.8.1
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
- '@atproto/common-web@0.5.0':
+ '@atproto/common-web@0.5.1':
dependencies:
- '@atproto/lex-data': 0.1.1
- '@atproto/lex-json': 0.1.0
- '@atproto/syntax': 0.6.1
+ '@atproto/lex-data': 0.1.2
+ '@atproto/lex-json': 0.1.1
+ '@atproto/syntax': 0.6.2
zod: 3.25.76
- '@atproto/lex-data@0.1.1':
+ '@atproto/lex-data@0.1.2':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
- '@atproto/lex-json@0.1.0':
+ '@atproto/lex-json@0.1.1':
dependencies:
- '@atproto/lex-data': 0.1.1
+ '@atproto/lex-data': 0.1.2
tslib: 2.8.1
- '@atproto/lexicon@0.7.1':
+ '@atproto/lexicon@0.7.2':
dependencies:
- '@atproto/common-web': 0.5.0
- '@atproto/syntax': 0.6.1
+ '@atproto/common-web': 0.5.1
+ '@atproto/syntax': 0.6.2
multiformats: 13.4.2
zod: 3.25.76
@@ -9514,9 +9518,14 @@ snapshots:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
- '@atproto/xrpc@0.8.0':
+ '@atproto/syntax@0.6.2':
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
'@babel/code-frame@7.10.4':
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 3e9a9559d6..e34feae3a5 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native'
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 FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -252,23 +253,6 @@ export type Events = {
'composer:image:edit': {
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:camera:press': {}
'composerPrompt:gallery:press': {}
@@ -1348,4 +1332,99 @@ export type Events = {
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'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
+ }
}
diff --git a/src/components/AltBadgeWithDialog.tsx b/src/components/AltBadgeWithDialog.tsx
new file mode 100644
index 0000000000..07cf9544f0
--- /dev/null
+++ b/src/components/AltBadgeWithDialog.tsx
@@ -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 (
+ <>
+ [
+ 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,
+ },
+ ]
+ : [],
+ ]}>
+
+ ALT
+
+
+
+
+
+
+ Alt Text
+
+ {text}
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/AppLanguageDropdown.tsx b/src/components/AppLanguageDropdown.tsx
index 4d66ba6db5..1114310f44 100644
--- a/src/components/AppLanguageDropdown.tsx
+++ b/src/components/AppLanguageDropdown.tsx
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
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 {Button} from './Button'
+import {Button, ButtonIcon} from './Button'
+import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe'
export function AppLanguageDropdown() {
const t = useTheme()
@@ -57,6 +58,7 @@ export function AppLanguageDropdown() {
native: [a.gap_xs],
}),
]}>
+
(
-
+
{label}
diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
index 4cf95e0fab..24ea286ada 100644
--- a/src/components/Post/Embed/ExternalEmbed/Gif.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
@@ -66,19 +66,7 @@ export function GifEmbed({
a.overflow_hidden,
{backgroundColor: t.palette.black},
]}>
-
+
@@ -64,74 +60,41 @@ export function GifPresentationControls({
]}
/>
)}
-
-
- GIF
-
+
+
+
+ GIF
+
+
+ {altText && }
- {altText && }
>
)
}
-
-function AltBadge({text}: {text: string}) {
- const control = Prompt.usePromptControl()
- const {_} = useLingui()
-
- return (
- <>
-
-
- ALT
-
-
-
-
-
- Alt Text
-
- {text}
-
-
- control.close()}
- cta={_(msg`Close`)}
- color="secondary"
- />
-
-
- >
- )
-}
-
-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,
- },
-})
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 05e8b84581..f38677ea1d 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
+import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
@@ -98,19 +99,24 @@ export function VideoEmbedInnerNative({
altText={embed.alt}
/>
) : (
- {
- videoRef.current?.enterFullscreen(true)
- }}
- toggleMuted={() => {
- videoRef.current?.toggleMuted()
- }}
- togglePlayback={() => {
- videoRef.current?.togglePlayback()
- }}
- isPlaying={isPlaying}
- timeRemaining={timeRemaining}
- />
+ <>
+ {
+ videoRef.current?.enterFullscreen(true)
+ }}
+ toggleMuted={() => {
+ videoRef.current?.toggleMuted()
+ }}
+ togglePlayback={() => {
+ videoRef.current?.togglePlayback()
+ }}
+ isPlaying={isPlaying}
+ timeRemaining={timeRemaining}
+ />
+ {embed.alt && (
+
+ )}
+ >
)}
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index d56c0b234d..b97317dfbf 100644
--- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
+import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
+import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls'
@@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({
const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId()
const {_} = useLingui()
+ const [isFullscreen] = useFullscreen(containerRef)
+ const isGif = embed.presentation === 'gif'
// send error up to error boundary
const [error, setError] = useState(null)
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
)}
+ {!isFullscreen && !isGif && embed.alt && (
+
+ )}
diff --git a/src/components/dialogs/NotificationSettingsDialog.tsx b/src/components/dialogs/NotificationSettingsDialog.tsx
index c62f6a7d98..bed3106c08 100644
--- a/src/components/dialogs/NotificationSettingsDialog.tsx
+++ b/src/components/dialogs/NotificationSettingsDialog.tsx
@@ -1,8 +1,12 @@
import {View} from 'react-native'
-import {type AppBskyNotificationDefs} from '@atproto/api'
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 {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
import {atoms as a, useTheme, web} from '#/alf'
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
type NotificationSettingsDialogProps = {
control: Dialog.DialogControlProps
- name: Exclude
- syncOthers?: Exclude[]
+ name: NotificationSettingsPreferenceName
+ syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType
titleText: React.ReactNode
subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit) {
const t = useTheme()
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 (
<>
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
)}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
{IS_NATIVE && (
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 9621cafc88..e705f0ce95 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -1,8 +1,7 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
import {AtUri} from '@atproto/api'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {useLingui} from '@lingui/react/macro'
import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -108,6 +107,7 @@ let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() {
const ax = useAnalytics()
+ // eslint-disable-next-line react-compiler/react-compiler
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession()
@@ -116,7 +116,7 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
- const {_} = useLingui()
+ const {t: l} = useLingui()
// 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
@@ -129,14 +129,12 @@ export function useNotificationsHandler() {
// NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat'
- Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
- name: _(msg`Chat`),
- description: _(
- msg`You can choose whether chat notifications have sound in the chat settings within the app`,
- ),
+ void Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
+ name: l`Chat`,
+ description: l`You can choose whether chat notifications have sound in the chat settings within the app`,
})
- Notifications.setNotificationChannelAsync('chat-messages', {
- name: _(msg`Chat messages - sound`),
+ void Notifications.setNotificationChannelAsync('chat-messages', {
+ name: l`Chat messages - sound`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3',
@@ -144,8 +142,8 @@ export function useNotificationsHandler() {
vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
- Notifications.setNotificationChannelAsync('chat-messages-muted', {
- name: _(msg`Chat messages - silent`),
+ void Notifications.setNotificationChannelAsync('chat-messages-muted', {
+ name: l`Chat messages - silent`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: null,
@@ -154,70 +152,70 @@ export function useNotificationsHandler() {
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'like' satisfies NotificationReason,
{
- name: _(msg`Likes`),
+ name: l`Likes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'repost' satisfies NotificationReason,
{
- name: _(msg`Reposts`),
+ name: l`Reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'reply' satisfies NotificationReason,
{
- name: _(msg`Replies`),
+ name: l`Replies`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'mention' satisfies NotificationReason,
{
- name: _(msg`Mentions`),
+ name: l`Mentions`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'quote' satisfies NotificationReason,
{
- name: _(msg`Quotes`),
+ name: l`Quotes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'follow' satisfies NotificationReason,
{
- name: _(msg`New followers`),
+ name: l`New followers`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies NotificationReason,
{
- name: _(msg`Likes of your reposts`),
+ name: l`Likes of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies NotificationReason,
{
- name: _(msg`Reposts of your reposts`),
+ name: l`Reposts of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- Notifications.setNotificationChannelAsync(
+ void Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason,
{
- name: _(msg`Activity from others`),
+ name: l`Activity from others`,
importance: Notifications.AndroidImportance.HIGH,
},
)
- }, [_])
+ }, [l])
useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
@@ -237,7 +235,7 @@ export function useNotificationsHandler() {
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
- onPressSwitchAccount(account, 'Notification')
+ void onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
@@ -305,10 +303,10 @@ export function useNotificationsHandler() {
}
Notifications.setNotificationHandler({
- handleNotification: async e => {
+ handleNotification: 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})
@@ -323,17 +321,17 @@ export function useNotificationsHandler() {
payload.reason === 'chat-removed-from-group' ||
payload.reason === 'chat-join-request-rejected' ||
payload.convoId !== currentConvoId
- return {
+ return Promise.resolve({
shouldShowList: shouldAlert,
shouldShowBanner: shouldAlert,
shouldPlaySound: false,
shouldSetBadge: false,
- } satisfies Notifications.NotificationBehavior
+ } satisfies Notifications.NotificationBehavior)
}
// Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage()
- return DEFAULT_HANDLER_OPTIONS
+ return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
},
})
@@ -363,14 +361,14 @@ export function useNotificationsHandler() {
})
invalidateCachedUnreadPage()
- truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
+ void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if (
payload.reason === 'mention' ||
payload.reason === 'quote' ||
payload.reason === 'reply'
) {
- truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
+ void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
logger.debug('Notifications: handleNotification', {
@@ -379,7 +377,7 @@ export function useNotificationsHandler() {
})
handleNotification(payload)
- Notifications.dismissAllNotificationsAsync()
+ void Notifications.dismissAllNotificationsAsync()
// Also clear the native `lastResponse` cache. Otherwise a subsequent
// `getLastNotificationResponse()` (e.g. on an account-switch remount,
// which re-runs `handlePushNotificationEntry`) would replay this
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index 5771d352ab..7b14961653 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -6,11 +6,7 @@ import {
VIDEO_MAX_SIZE,
} from '#/lib/constants'
import {logger} from '#/logger'
-import {
- compress,
- probe,
- type VideoMetadata,
-} from '../../../../modules/expo-bluesky-video-compress'
+import {compress, probe} from '../../../../modules/expo-bluesky-video-compress'
import {
COMPRESSION_MAX_DIMENSION,
COMPRESSION_PASSTHROUGH_BITRATE,
@@ -23,12 +19,15 @@ export async function compressVideo(
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
- onProbe?: (metadata: VideoMetadata, wouldCompress: boolean) => void
- onProbeFailed?: (error: unknown) => void
},
): Promise {
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(
@@ -42,22 +41,20 @@ export async function compressVideo(
logger.debug('probe failed, falling through to passthrough', {
safeMessage: e,
})
- opts?.onProbeFailed?.(e)
return {
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: file.mimeType ?? 'video/mp4',
+ passthroughReason: 'compress-error-fallback',
}
}
- const willCompress = shouldCompress(metadata, isAcceptableFormat)
- opts?.onProbe?.(metadata, willCompress)
-
- if (!willCompress) {
+ if (!shouldCompress(metadata, isAcceptableFormat)) {
return {
uri: file.uri,
size: metadata.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
+ passthroughReason: 'below-thresholds',
}
}
diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts
index 96e0fc43f0..62e8469351 100644
--- a/src/lib/media/video/compress.web.ts
+++ b/src/lib/media/video/compress.web.ts
@@ -47,6 +47,7 @@ export async function compressVideo(
const blob = await response.blob()
const isGif = blob.type === 'image/gif'
+ const hasCodecs = hasWebCodecs()
logger.debug('compress: fetched blob', {
size: blob.size,
@@ -57,22 +58,32 @@ export async function compressVideo(
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
- if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) {
+ let fallbackReason: NonNullable | 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 {
return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e,
})
+ fallbackReason = 'compress-error-fallback'
}
- } else {
- logger.debug('compress: skipping compression', {
- hasWebCodecs: hasWebCodecs(),
- blobSize: blob.size,
- minSize: COMPRESSION_MIN_SIZE_BYTES,
- })
}
+ logger.debug('compress: skipping compression', {
+ hasWebCodecs: hasCodecs,
+ blobSize: blob.size,
+ minSize: COMPRESSION_MIN_SIZE_BYTES,
+ reason: fallbackReason,
+ })
+
// No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
@@ -83,6 +94,7 @@ export async function compressVideo(
size: blob.size,
bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4',
+ passthroughReason: fallbackReason,
}
}
diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts
new file mode 100644
index 0000000000..7ef5a874ef
--- /dev/null
+++ b/src/lib/media/video/telemetry.ts
@@ -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 = (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 | 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,
+ })
+ },
+ }
+}
diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts
index ae873d7565..a6e35bcf80 100644
--- a/src/lib/media/video/types.ts
+++ b/src/lib/media/video/types.ts
@@ -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 = {
uri: string
mimeType: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
+ // Set when the engine returned the input unchanged. Undefined means the
+ // bytes were actually re-encoded.
+ passthroughReason?: VideoCompressSkipReason
}
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index 78455288dd..2408fefc62 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -1956,10 +1956,10 @@ msgstr ""
#: src/components/dms/dialogs/NewChatDialog.tsx:155
#: src/components/dms/MessageProfileButton.tsx:60
-#: src/screens/Messages/ChatList.tsx:155
-#: src/screens/Messages/ChatList.tsx:173
-#: src/screens/Messages/ChatList.tsx:274
-#: src/screens/Messages/ChatList.tsx:527
+#: src/screens/Messages/ChatList.tsx:166
+#: src/screens/Messages/ChatList.tsx:184
+#: src/screens/Messages/ChatList.tsx:285
+#: src/screens/Messages/ChatList.tsx:541
#: src/screens/Messages/Conversation.tsx:203
#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:99
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"
msgstr "Block user and/or leave this conversation"
-#: src/components/Post/Embed/index.tsx:216
+#: src/components/Post/Embed/index.tsx:212
msgid "Blocked"
msgstr ""
@@ -2335,7 +2335,7 @@ msgstr "Camera access needed"
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:299
#: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:175
#: 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/PostControls/RepostButton.tsx:210
#: src/components/Prompt.tsx:152
@@ -2531,6 +2531,12 @@ msgstr ""
msgid "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
msgid "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/Navigation.tsx:506
-#: src/screens/Messages/ChatList.tsx:84
-#: src/screens/Messages/ChatList.tsx:88
-#: src/screens/Messages/ChatList.tsx:552
-#: src/screens/Messages/ChatList.tsx:583
+#: src/screens/Messages/ChatList.tsx:95
+#: src/screens/Messages/ChatList.tsx:99
+#: src/screens/Messages/ChatList.tsx:652
+#: src/screens/Messages/ChatList.tsx:662
#: src/screens/Messages/Settings.tsx:39
msgid "Chat settings"
msgstr ""
@@ -2580,9 +2586,9 @@ msgctxt "toast"
msgid "Chat unmuted"
msgstr ""
-#: src/screens/Messages/ChatList.tsx:78
-#: src/screens/Messages/ChatList.tsx:539
-#: src/screens/Messages/ChatList.tsx:576
+#: src/screens/Messages/ChatList.tsx:89
+#: src/screens/Messages/ChatList.tsx:553
+#: src/screens/Messages/ChatList.tsx:593
msgid "Chats"
msgstr ""
@@ -2656,7 +2662,7 @@ msgstr ""
msgid "Choose your password"
msgstr ""
-#: src/screens/Signup/index.tsx:192
+#: src/screens/Signup/index.tsx:193
msgid "Choose your username"
msgstr ""
@@ -2857,7 +2863,7 @@ msgstr "Close the incoming requests banner"
#: src/components/intents/GroupChatJoinDialog.tsx:240
#: src/components/intents/GroupChatJoinDialog.tsx:276
#: src/components/intents/GroupChatJoinDialog.tsx:277
-#: src/components/Menu/index.tsx:361
+#: src/components/Menu/index.tsx:367
msgid "Close this dialog"
msgstr ""
@@ -2881,7 +2887,7 @@ msgstr ""
msgid "Collapses list of users for a given notification"
msgstr ""
-#: src/features/inviteFriends/components/ThemePicker.tsx:66
+#: src/features/inviteFriends/components/ThemePicker.tsx:68
msgid "Color"
msgstr "Color"
@@ -2912,7 +2918,7 @@ msgstr ""
msgid "Complete onboarding and start using your account"
msgstr ""
-#: src/screens/Signup/index.tsx:194
+#: src/screens/Signup/index.tsx:195
msgid "Complete the challenge"
msgstr ""
@@ -2989,8 +2995,8 @@ msgid "Contact our support team"
msgstr ""
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:156
-#: src/screens/Signup/index.tsx:233
-#: src/screens/Signup/index.tsx:236
+#: src/screens/Signup/index.tsx:240
+#: src/screens/Signup/index.tsx:243
msgid "Contact support"
msgstr ""
@@ -3130,7 +3136,7 @@ msgid "Copied build version to clipboard"
msgstr ""
#: 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/PostMenu/PostMenuItems.tsx:273
#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77
@@ -3222,8 +3228,8 @@ msgstr ""
msgid "Copy link to starter pack"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:183
-#: src/components/dms/MessageContextMenu.tsx:187
+#: src/components/dms/MessageContextMenu.tsx:198
+#: src/components/dms/MessageContextMenu.tsx:202
msgid "Copy message text"
msgstr ""
@@ -3398,7 +3404,7 @@ msgstr ""
#: src/components/WelcomeModal.tsx:158
#: src/components/WelcomeModal.tsx:166
#: 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.web.tsx:116
#: src/view/shell/bottom-bar/BottomBar.tsx:345
@@ -3544,7 +3550,7 @@ msgstr ""
msgid "Dawn"
msgstr "Dawn"
-#: src/features/inviteFriends/components/ThemePicker.tsx:29
+#: src/features/inviteFriends/components/ThemePicker.tsx:30
msgid "Day"
msgstr "Day"
@@ -3627,7 +3633,7 @@ msgstr ""
msgid "Delete conversation"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:197
+#: src/components/dms/MessageContextMenu.tsx:212
msgid "Delete for me"
msgstr ""
@@ -3640,7 +3646,7 @@ msgstr ""
msgid "Delete message"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:194
+#: src/components/dms/MessageContextMenu.tsx:209
msgid "Delete message for me"
msgstr ""
@@ -3671,7 +3677,7 @@ msgstr ""
msgid "Delete this post?"
msgstr ""
-#: src/components/Post/Embed/index.tsx:209
+#: src/components/Post/Embed/index.tsx:205
msgid "Deleted"
msgstr ""
@@ -4040,7 +4046,7 @@ msgstr ""
msgid "Duration:"
msgstr ""
-#: src/features/inviteFriends/components/ThemePicker.tsx:30
+#: src/features/inviteFriends/components/ThemePicker.tsx:31
msgid "Dusk"
msgstr "Dusk"
@@ -4171,12 +4177,12 @@ msgstr ""
#: src/screens/Profile/Header/EditProfileDialog.tsx:265
#: 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
msgid "Edit profile"
msgstr ""
-#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:299
+#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:302
#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:352
msgid "Edit Profile"
msgstr ""
@@ -4598,7 +4604,7 @@ msgid "Failed to accept join request"
msgstr "Failed to accept join request"
#: 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"
msgstr ""
@@ -4693,10 +4699,6 @@ msgstr ""
msgid "Failed to hide suggestion, please check your internet connection"
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
msgid "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"
msgstr "Failed to leave group chat"
-#: src/screens/Messages/ChatList.tsx:404
+#: src/screens/Messages/ChatList.tsx:418
#: src/screens/Messages/Inbox.tsx:209
msgid "Failed to load conversations"
msgstr ""
@@ -4762,6 +4764,7 @@ msgstr ""
msgid "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
msgid "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}"
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
msgid "Failed to remove data due to a network error, please check your internet connection."
msgstr ""
@@ -4794,7 +4801,7 @@ msgid "Failed to remove data. {0}"
msgstr ""
#: 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
msgid "Failed to remove emoji reaction"
msgstr ""
@@ -5779,7 +5786,7 @@ msgstr ""
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>"
-#: src/screens/Signup/index.tsx:231
+#: src/screens/Signup/index.tsx:238
msgid "Having trouble?"
msgstr ""
@@ -6224,7 +6231,7 @@ msgstr "In-app, push, everyone"
msgid "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"
msgstr "Inbox empty"
@@ -6403,8 +6410,8 @@ msgid "Invites, but personal"
msgstr ""
#: src/ageAssurance/components/NoAccessScreen.tsx:394
-msgid "Is your location not accurate? <0>Tap here to update your location with GPS.0> "
-msgstr "Is your location not accurate? <0>Tap here to update your location with GPS.0> "
+msgid "Is your location not accurate? <0>Tap here to update your location with GPS.0>"
+msgstr "Is your location not accurate? <0>Tap here to update your location with GPS.0>"
#: src/components/contacts/components/InviteInfo.tsx:47
msgid "It looks like some of your contacts have not tried to find you here yet. You can personally invite them by customizing a draft message we will provide."
@@ -6474,11 +6481,6 @@ msgstr ""
msgid "Keep me posted"
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
msgid "KWS website"
msgstr ""
@@ -6949,9 +6951,7 @@ msgstr ""
msgid "Load new posts"
msgstr ""
-#: src/screens/Messages/components/MessageComposer.tsx:245
-#: src/screens/Messages/components/MessageInput.tsx:258
-#: src/screens/Messages/components/MessageInput.web.tsx:228
+#: src/screens/Messages/components/MessageComposer.tsx:254
msgctxt "placeholder"
msgid "Loading chat…"
msgstr "Loading chat…"
@@ -7065,6 +7065,8 @@ msgstr ""
msgid "Mark all as read"
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:463
msgid "Mark all chats as read"
@@ -7084,6 +7086,7 @@ msgstr ""
msgid "Marked all as read"
msgstr ""
+#: src/screens/Messages/ChatList.tsx:631
#: src/view/shell/bottom-bar/BottomBar.tsx:438
msgid "Marked all chats as read"
msgstr "Marked all chats as read"
@@ -7139,19 +7142,17 @@ msgstr ""
msgid "Mentions"
msgstr ""
-#: src/components/Menu/index.tsx:113
+#: src/components/Menu/index.tsx:119
msgid "Menu"
msgstr ""
-#: src/screens/Messages/components/MessageInput.tsx:202
+#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203
+msgctxt "action"
msgid "Message"
msgstr "Message"
-#: src/screens/Messages/components/MessageComposer.tsx:246
-#: src/screens/Messages/components/MessageInput.tsx:259
-#: src/screens/Messages/components/MessageInput.web.tsx:229
-#: src/screens/Messages/ConversationSettings/MemberMenu.tsx:203
-msgctxt "action"
+#: src/screens/Messages/components/MessageComposer.tsx:255
+msgctxt "description"
msgid "Message"
msgstr "Message"
@@ -7179,7 +7180,7 @@ msgstr "Message failed to send."
#. placeholder {0}: sender?.handle ?? 'unknown'
#. placeholder {1}: message.text
-#: src/components/dms/MessageContextMenu.tsx:152
+#: src/components/dms/MessageContextMenu.tsx:166
msgid "Message from @{0}: {1}"
msgstr ""
@@ -7188,21 +7189,15 @@ msgstr ""
msgid "Message from server: {0}"
msgstr ""
-#: src/screens/Messages/components/MessageComposer.tsx:242
-#: src/screens/Messages/components/MessageInput.tsx:200
+#: src/screens/Messages/components/MessageComposer.tsx:251
msgid "Message input field"
msgstr ""
-#: src/screens/Messages/components/MessageInput.tsx:96
-#: src/screens/Messages/components/MessageInput.web.tsx:65
-msgid "Message is too long"
-msgstr ""
-
-#: src/screens/Messages/components/MessageComposer.tsx:107
+#: src/screens/Messages/components/MessageComposer.tsx:116
msgid "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"
msgstr ""
@@ -7510,7 +7505,6 @@ msgstr ""
msgid "Nevermind, create a handle for me"
msgstr ""
-#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171
#: src/screens/Search/modules/ExploreTrendingTopics.tsx:184
#: src/view/com/profile/ProfileMenu.tsx:398
msgid "New"
@@ -7522,6 +7516,11 @@ msgctxt "action"
msgid "New"
msgstr ""
+#: src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx:171
+msgctxt "nux-description"
+msgid "New"
+msgstr "New"
+
#: src/view/com/notifications/NotificationFeedItem.tsx:569
msgid "New {postsCount, plural, one {post} other {posts}} from {firstAuthorLink}"
msgstr ""
@@ -7532,11 +7531,11 @@ msgstr ""
#: src/components/dms/dialogs/NewChatDialog.tsx:169
#: src/components/dms/dialogs/NewChatDialog.tsx:184
-#: src/screens/Messages/ChatList.tsx:218
-#: src/screens/Messages/ChatList.tsx:219
-#: src/screens/Messages/ChatList.tsx:439
-#: src/screens/Messages/ChatList.tsx:440
-#: src/screens/Messages/ChatList.tsx:561
+#: src/screens/Messages/ChatList.tsx:229
+#: src/screens/Messages/ChatList.tsx:230
+#: src/screens/Messages/ChatList.tsx:453
+#: src/screens/Messages/ChatList.tsx:454
+#: src/screens/Messages/ChatList.tsx:578
msgid "New chat"
msgstr ""
@@ -7676,7 +7675,7 @@ msgstr ""
msgid "Next image"
msgstr ""
-#: src/features/inviteFriends/components/ThemePicker.tsx:31
+#: src/features/inviteFriends/components/ThemePicker.tsx:33
msgid "Night"
msgstr "Night"
@@ -8087,8 +8086,8 @@ msgid "Only admins can accept join requests."
msgstr "Only admins can accept join requests."
#: src/screens/Messages/JoinRequests.tsx:233
-msgid "Only admins can ignore join requests."
-msgstr "Only admins can ignore join requests."
+msgid "Only admins can reject join requests."
+msgstr "Only admins can reject join requests."
#: src/components/intents/GroupChatJoinDialog.tsx:155
msgid "Only followers can join this group chat."
@@ -8165,8 +8164,7 @@ msgstr ""
msgid "Open drawer menu"
msgstr ""
-#: src/screens/Messages/components/MessageComposer.tsx:204
-#: src/screens/Messages/components/MessageInput.web.tsx:174
+#: src/screens/Messages/components/MessageComposer.tsx:213
#: src/view/com/composer/Composer.tsx:2158
msgid "Open emoji picker"
msgstr ""
@@ -8275,7 +8273,7 @@ msgstr ""
msgid "Opens a dialog to choose who can interact with this post"
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"
msgstr "Opens a list of color themes for the QR card"
@@ -8605,7 +8603,7 @@ msgstr ""
msgid "Photography"
msgstr ""
-#: src/features/inviteFriends/components/ThemePicker.tsx:37
+#: src/features/inviteFriends/components/ThemePicker.tsx:39
msgid "Pick a color theme"
msgstr "Pick a color theme"
@@ -9334,7 +9332,7 @@ msgstr ""
msgid "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
msgid "Reload conversations"
msgstr ""
@@ -9461,6 +9459,11 @@ msgstr ""
msgid "Remove live status"
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
msgid "Remove mute word from your list"
msgstr ""
@@ -9502,11 +9505,11 @@ msgstr ""
msgid "Remove your verification for this account?"
msgstr ""
-#: src/components/Post/Embed/index.tsx:244
+#: src/components/Post/Embed/index.tsx:240
msgid "Removed by author"
msgstr ""
-#: src/components/Post/Embed/index.tsx:242
+#: src/components/Post/Embed/index.tsx:238
msgid "Removed by you"
msgstr ""
@@ -9604,8 +9607,8 @@ msgstr ""
msgid "Replies to this post are disabled."
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:167
-#: src/components/dms/MessageContextMenu.tsx:170
+#: src/components/dms/MessageContextMenu.tsx:182
+#: src/components/dms/MessageContextMenu.tsx:185
msgid "Reply"
msgstr "Reply"
@@ -9647,7 +9650,7 @@ msgstr ""
msgid "Reply was successfully hidden"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:205
+#: src/components/dms/MessageContextMenu.tsx:220
#: src/features/liveNow/components/LiveStatusDialog.tsx:267
#: src/screens/Messages/ConversationSettings/index.tsx:583
msgid "Report"
@@ -9680,7 +9683,7 @@ msgstr ""
msgid "Report list"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:202
+#: src/components/dms/MessageContextMenu.tsx:217
msgid "Report message"
msgstr ""
@@ -9808,8 +9811,8 @@ msgid "Request code"
msgstr ""
#: src/screens/Messages/JoinRequests.tsx:215
-msgid "Request ignored."
-msgstr "Request ignored."
+msgid "Request rejected."
+msgstr "Request rejected."
#: src/components/dms/ChatInvite/Root.tsx:117
#: src/components/intents/GroupChatJoinDialog.tsx:295
@@ -9929,7 +9932,7 @@ msgstr ""
#: src/components/StarterPack/ProfileStarterPacks.tsx:377
#: src/screens/Login/LoginForm.tsx:329
#: 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/Inbox.tsx:218
#: src/screens/Messages/JoinRequests.tsx:361
@@ -10084,8 +10087,8 @@ msgstr ""
msgid "Say hello!"
msgstr ""
-#: src/screens/Messages/ChatList.tsx:209
-#: src/screens/Messages/ChatList.tsx:430
+#: src/screens/Messages/ChatList.tsx:220
+#: src/screens/Messages/ChatList.tsx:444
msgid "Say hi to someone"
msgstr "Say hi to someone"
@@ -10486,8 +10489,7 @@ msgstr "Send error report"
msgid "Send feedback"
msgstr ""
-#: src/screens/Messages/components/MessageComposer.tsx:316
-#: src/screens/Messages/components/MessageInput.web.tsx:251
+#: src/screens/Messages/components/MessageComposer.tsx:324
msgid "Send message"
msgstr ""
@@ -10521,7 +10523,7 @@ msgid "Send via direct message"
msgstr ""
#. 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}"
msgstr "Sent at {0}"
@@ -11218,7 +11220,7 @@ msgstr ""
#. placeholder {1}: state.serviceDescription && !state.serviceDescription.phoneVerificationRequired ? '2' : '3'
#. placeholder {1}: state.totalSteps
#: src/screens/Onboarding/Layout.tsx:226
-#: src/screens/Signup/index.tsx:180
+#: src/screens/Signup/index.tsx:181
msgid "Step {0} of {1}"
msgstr ""
@@ -11290,11 +11292,11 @@ msgstr ""
msgid "Subscribe to account activity"
msgstr ""
-#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:347
+#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:350
msgid "Subscribe to Labeler"
msgstr ""
-#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:313
+#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:316
msgid "Subscribe to this labeler"
msgstr ""
@@ -11340,6 +11342,10 @@ msgstr ""
msgid "Suggestive"
msgstr ""
+#: src/features/inviteFriends/components/ThemePicker.tsx:29
+msgid "Sunlight"
+msgstr "Sunlight"
+
#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72
msgctxt "Name of app icon variant"
msgid "Sunrise"
@@ -12299,8 +12305,8 @@ msgstr ""
msgid "Topic"
msgstr ""
-#: src/components/dms/MessageContextMenu.tsx:176
-#: src/components/dms/MessageContextMenu.tsx:179
+#: src/components/dms/MessageContextMenu.tsx:191
+#: src/components/dms/MessageContextMenu.tsx:194
#: src/components/Post/Translated/index.tsx:150
#: src/components/Post/Translated/index.tsx:157
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:557
@@ -12388,14 +12394,14 @@ msgstr "Try it"
msgid "TV"
msgstr ""
+#: src/features/inviteFriends/components/ThemePicker.tsx:32
+msgid "Twilight"
+msgstr "Twilight"
+
#: src/screens/Settings/PrivacyAndSecuritySettings.tsx:65
msgid "Two-factor authentication (2FA)"
msgstr ""
-#: src/screens/Messages/components/MessageInput.tsx:201
-msgid "Type your message here"
-msgstr ""
-
#: src/screens/Settings/components/ChangeHandleDialog.tsx:445
msgid "Type:"
msgstr ""
@@ -12418,7 +12424,7 @@ msgstr ""
#: src/screens/Login/index.tsx:96
#: src/screens/Login/LoginForm.tsx:167
#: 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."
msgstr ""
@@ -12677,7 +12683,7 @@ msgstr ""
msgid "Unsnooze email reminder"
msgstr ""
-#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:345
+#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:348
msgid "Unsubscribe"
msgstr ""
@@ -12686,7 +12692,7 @@ msgstr ""
msgid "Unsubscribe from list"
msgstr ""
-#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:312
+#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:315
msgid "Unsubscribe from this labeler"
msgstr ""
@@ -13455,7 +13461,7 @@ msgstr "We’re having network issues, try again"
msgid "We’re introducing a new layer of verification on Bluesky — an easy-to-see checkmark."
msgstr ""
-#: src/screens/Signup/index.tsx:136
+#: src/screens/Signup/index.tsx:137
msgid "We’re so excited to have you join us!"
msgstr "We’re so excited to have you join us!"
@@ -13559,7 +13565,7 @@ msgid "Who can verify?"
msgstr ""
#: src/screens/Home/NoFeedsPinned.tsx:80
-#: src/screens/Messages/ChatList.tsx:393
+#: src/screens/Messages/ChatList.tsx:407
#: src/screens/Messages/Inbox.tsx:198
msgid "Whoops!"
msgstr ""
@@ -13844,7 +13850,7 @@ msgid "You can update this later from your settings."
msgstr ""
#: 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}}"
msgstr "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}"
@@ -14112,11 +14118,11 @@ msgstr "You won’t be able to rejoin unless you’re invited."
msgid "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!"
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!"
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?"
msgstr ""
-#: src/screens/Signup/index.tsx:190
+#: src/screens/Signup/index.tsx:191
msgid "Your account"
msgstr ""
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
index 2fb58253a5..f6b4216b2c 100644
--- a/src/screens/Messages/Inbox.tsx
+++ b/src/screens/Messages/Inbox.tsx
@@ -23,6 +23,7 @@ import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
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 {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
@@ -84,16 +85,8 @@ export function MessagesInboxScreenInner({}: Props) {
return items
}, [data])
- const hasUnreadConvos = useMemo(() => {
- return conversations.some(
- item =>
- item.type === 'incoming' &&
- item.view.members.every(
- member => member.handle !== 'missing.invalid',
- ) &&
- item.view.unreadCount > 0,
- )
- }, [conversations])
+ const {data: unreadCounts} = useUnreadCountsQuery()
+ const hasUnreadConvos = (unreadCounts?.unreadRequestConvos ?? 0) > 0
return (
diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx
index 6c407a8301..07190d2be3 100644
--- a/src/screens/Messages/Settings.tsx
+++ b/src/screens/Messages/Settings.tsx
@@ -1,13 +1,19 @@
import {useCallback} from 'react'
-import {View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
+import {
+ type NotificationSettingsPreference,
+ useChatNotificationSettingsQuery,
+} from '#/state/queries/notifications/settings'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
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 {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
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 {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
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 Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
@@ -52,8 +61,12 @@ export function MessagesSettingsScreenInner({}: Props) {
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
+ const {data: chatNotificationSettings, isError: chatSettingsError} =
+ useChatNotificationSettingsQuery()
const {preferences, setPref} = useBackgroundNotificationPreferences()
+ const chatDialogControl = Dialog.useDialogControl()
+ const chatRequestDialogControl = Dialog.useDialogControl()
const exportCarControl = Dialog.useDialogControl()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -237,6 +250,54 @@ export function MessagesSettingsScreenInner({}: Props) {
>
) : null}
+
+
+ Notifications
+
+ {
+ chatDialogControl.open()
+ }}>
+
+
+
+ New messages
+
+
+
+
+
+ {
+ chatRequestDialogControl.open()
+ }}>
+
+
+
+ New message requests
+
+
+
+
+
+
+
{IS_NATIVE && (
<>
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
>
)}
- {
+ {
exportCarControl.open()
}}>
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
Export my chat data
-
+
+
)
}
+
+function NotificationPreferenceSubtitle({
+ preference,
+ isLoading,
+ isError,
+}: {
+ preference?: NotificationSettingsPreference
+ isLoading: boolean
+ isError: boolean
+}) {
+ const t = useTheme()
+
+ if (isError) {
+ return (
+
+ Failed to load notification settings.
+
+ )
+ }
+
+ if (isLoading) {
+ return
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/screens/Messages/components/InboxRequests.tsx b/src/screens/Messages/components/InboxRequests.tsx
index ba5f2bf385..bcea519100 100644
--- a/src/screens/Messages/components/InboxRequests.tsx
+++ b/src/screens/Messages/components/InboxRequests.tsx
@@ -1,14 +1,12 @@
import {plural} from '@lingui/core/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 {ButtonIcon, ButtonText} from '#/components/Button'
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
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({
count,
variant,
@@ -21,7 +19,7 @@ export function InboxRequests({
const {t: l} = useLingui()
const unread = count > 0
- const overflow = count >= REQUEST_COUNT_CAP
+ const overflow = count >= UNREAD_REQUEST_CAP
const label = !unread
? l({
@@ -30,8 +28,8 @@ export function InboxRequests({
})
: overflow
? l({
- message: `10+ requests`,
- comment: 'Displayed when the number of requests is greater than 10',
+ message: `${UNREAD_REQUEST_CAP - 1}+ requests`,
+ comment: 'Displayed when the number of requests exceeds the cap',
})
: plural(count, {
one: '# request',
@@ -55,9 +53,9 @@ export function InboxRequests({
{overflow
? l({
- message: `10+`,
+ message: `${UNREAD_REQUEST_CAP - 1}+`,
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}
diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx
index 5853e46d81..6b28b2f7b1 100644
--- a/src/screens/Messages/components/MessageInputReply.tsx
+++ b/src/screens/Messages/components/MessageInputReply.tsx
@@ -1,5 +1,5 @@
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 {HITSLOP_20} from '#/lib/constants'
@@ -8,6 +8,7 @@ import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useMessageReplies} from '#/components/dms/MessageReplies'
+import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -16,15 +17,27 @@ import {Text} from '#/components/Typography'
* being replied to, with a button to cancel the reply.
*/
export function MessageInputReply() {
- const t = useTheme()
- const {t: l} = useLingui()
- const convo = useConvoActive()
- const {replyTo, clearReply} = useMessageReplies()
+ const {replyTo} = useMessageReplies()
if (!replyTo) {
return null
}
+ return
+}
+
+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 = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
clearReply()
@@ -35,19 +48,6 @@ export function MessageInputReply() {
? createSanitizedDisplayName(senderProfile, false)
: 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 (
diff --git a/src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx b/src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx
new file mode 100644
index 0000000000..9aa1d9d684
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx
@@ -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 (
+ <>
+ New messages}
+ subtitleText={
+ Get notifications when people send you messages.
+ }
+ allowDisableInApp={false}
+ />
+ New message requests}
+ subtitleText={
+
+ Get notifications when people send you message requests.
+
+ }
+ allowDisableInApp={false}
+ />
+ >
+ )
+}
diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
index 14ee7054aa..8be51b93d7 100644
--- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
+++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
@@ -1,9 +1,12 @@
import {useMemo} from 'react'
import {View} from 'react-native'
-import {type AppBskyNotificationDefs} from '@atproto/api'
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 * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
@@ -17,16 +20,13 @@ export function PreferenceControls({
preference,
allowDisableInApp = true,
}: {
- name: Exclude
+ name: NotificationSettingsPreferenceName
/**
* 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.
*/
- syncOthers?: Exclude[]
- preference?:
- | AppBskyNotificationDefs.Preference
- | AppBskyNotificationDefs.FilterablePreference
- | AppBskyNotificationDefs.ChatPreference
+ syncOthers?: NotificationSettingsPreferenceName[]
+ preference?: NotificationSettingsPreference
allowDisableInApp?: boolean
}) {
if (!preference)
@@ -52,12 +52,9 @@ export function Inner({
preference,
allowDisableInApp,
}: {
- name: Exclude
- syncOthers?: Exclude[]
- preference:
- | AppBskyNotificationDefs.Preference
- | AppBskyNotificationDefs.FilterablePreference
- | AppBskyNotificationDefs.ChatPreference
+ name: NotificationSettingsPreferenceName
+ syncOthers?: NotificationSettingsPreferenceName[]
+ preference: NotificationSettingsPreference
allowDisableInApp: boolean
}) {
const t = useTheme()
@@ -95,7 +92,7 @@ export function Inner({
}
const onChangeFilter = ([change]: string[]) => {
- if (change !== 'all' && change !== 'follows' && change !== 'accepted')
+ if (change !== 'all' && change !== 'follows')
throw new Error('Invalid filter')
const newPreference = {
@@ -135,7 +132,7 @@ export function Inner({
- {allowDisableInApp && (
+ {allowDisableInApp && 'list' in preference && (
)}
- {name === 'chat' ? (
-
- {({selected}) => (
-
- )}
-
- ) : (
-
- {({selected}) => (
-
- )}
-
- )}
+
+ {({selected}) => (
+
+ )}
+
>
diff --git a/src/screens/Settings/NotificationSettings/components/SettingPreview.tsx b/src/screens/Settings/NotificationSettings/components/SettingPreview.tsx
new file mode 100644
index 0000000000..5e549d2e01
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/components/SettingPreview.tsx
@@ -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`
+}
diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx
index 29d27ce661..7df759de32 100644
--- a/src/screens/Settings/NotificationSettings/index.tsx
+++ b/src/screens/Settings/NotificationSettings/index.tsx
@@ -1,7 +1,6 @@
import {useEffect} from 'react'
import {Linking, View} from 'react-native'
import * as Notification from 'expo-notifications'
-import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQuery, useQueryClient} from '@tanstack/react-query'
@@ -10,7 +9,10 @@ import {
type AllNavigatorParams,
type NativeStackScreenProps,
} 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 {Admonition} from '#/components/Admonition'
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 {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
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 {
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
} 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 {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
import {
@@ -33,7 +37,9 @@ import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/S
import * as Layout from '#/components/Layout'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import * as SettingsList from '../components/SettingsList'
+import {ChatNotificationDialogs} from './components/ChatNotificationDialogs'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {SettingPreview} from './components/SettingPreview'
const RQKEY = ['notification-permissions']
@@ -42,6 +48,8 @@ export function NotificationSettingsScreen({}: Props) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {data: settings, isError} = useNotificationSettingsQuery()
+ const {data: chatSettings, isError: chatError} =
+ useChatNotificationSettingsQuery()
const likeDialogControl = Dialog.useDialogControl()
const followDialogControl = Dialog.useDialogControl()
@@ -52,6 +60,8 @@ export function NotificationSettingsScreen({}: Props) {
const activityDialogControl = Dialog.useDialogControl()
const likeRepostDialogControl = Dialog.useDialogControl()
const repostRepostDialogControl = Dialog.useDialogControl()
+ const chatDialogControl = Dialog.useDialogControl()
+ const chatRequestDialogControl = Dialog.useDialogControl()
const miscDialogControl = Dialog.useDialogControl()
const {data: permissions, refetch} = useQuery({
@@ -234,6 +244,40 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
+
+
+ New messages}
+ subtitleText={
+ chatError ? (
+ Failed to load notification settings.
+ ) : (
+
+ )
+ }
+ showSkeleton={!chatSettings && !chatError}
+ />
+
+
+
+ New message requests}
+ subtitleText={
+ chatError ? (
+ Failed to load notification settings.
+ ) : (
+
+ )
+ }
+ showSkeleton={!chatSettings && !chatError}
+ />
+
Get notifications when people repost your reposts.
}
/>
+
)
}
-
-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`
-}
diff --git a/src/state/queries/messages/get-unread-counts.ts b/src/state/queries/messages/get-unread-counts.ts
index 57b565fa17..8c276d67f7 100644
--- a/src/state/queries/messages/get-unread-counts.ts
+++ b/src/state/queries/messages/get-unread-counts.ts
@@ -10,13 +10,12 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
-// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31
-// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than
-// 10"). at the cap the value is no longer an exact count, so consumers must not
-// treat it as one - both the optimistic decrement and the badge display ceiling
-// key off these.
-export const UNREAD_ACCEPTED_CAP = 31
-export const UNREAD_REQUEST_CAP = 11
+// the server sentinel-caps the badge counts: unreadAcceptedConvos and
+// unreadRequestConvos max out at 100 (meaning "more than 99"). at the cap the
+// value is no longer an exact count, so consumers must not treat it as one -
+// both the optimistic decrement and the badge display ceiling key off these.
+export const UNREAD_ACCEPTED_CAP = 100
+export const UNREAD_REQUEST_CAP = 100
export function useUnreadCountsQuery() {
const agent = useAgent()
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index 3a3183bad1..1f73c720bd 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -856,18 +856,16 @@ export function useUnreadMessageCount(): {
const request = data?.unreadRequestConvos ?? 0
if (accepted > 0) {
- const total = accepted + Math.min(request, 1)
return {
- count: total,
+ count: accepted,
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
// cap - 1"). show the "+" overflow label only when accepted is actually
- // capped - the +1 request nudge must not trip it at exactly cap - 1
- // accepted convos. otherwise clamp the number to cap - 1 so the nudge
- // never surfaces the sentinel value (31) itself
+ // capped, otherwise clamp the number to cap - 1 so we never surface the
+ // sentinel value (100) itself
numUnread:
accepted >= UNREAD_ACCEPTED_CAP
? `${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
hasNew: false,
}
diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts
index 7c26491602..86e8a6c7f1 100644
--- a/src/state/queries/notifications/settings.ts
+++ b/src/state/queries/notifications/settings.ts
@@ -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 {
type QueryClient,
@@ -7,12 +10,59 @@ import {
useQueryClient,
} from '@tanstack/react-query'
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import * as Toast from '#/components/Toast'
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>
+
+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
+
+type AppNotificationSettingsUpdate = Partial<
+ Omit
+>
+
+type ChatNotificationSettingsUpdate =
+ Partial
export function useNotificationSettingsQuery({
enabled,
@@ -20,10 +70,27 @@ export function useNotificationSettingsQuery({
const agent = useAgent()
return useQuery({
- queryKey: RQKEY,
- queryFn: async () => {
- const response = await agent.app.bsky.notification.getPreferences()
- return response.data.preferences
+ queryKey: RQKEY_APP,
+ queryFn: async (): Promise => {
+ const res = await agent.app.bsky.notification.getPreferences()
+ return appPreferencesWithoutChat(res.data.preferences)
+ },
+ enabled,
+ })
+}
+
+export function useChatNotificationSettingsQuery({
+ enabled,
+}: {enabled?: boolean} = {}) {
+ const agent = useAgent()
+
+ return useQuery({
+ queryKey: RQKEY_CHAT,
+ queryFn: async (): Promise => {
+ const res = await agent.chat.bsky.notification.getPreferences(undefined, {
+ headers: DM_SERVICE_HEADERS,
+ })
+ return chatPreferencesForSettings(res.data.preferences)
},
enabled,
})
@@ -33,19 +100,27 @@ export function useNotificationSettingsUpdateMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async (
- update: Partial,
- ) => {
- const response =
- await agent.app.bsky.notification.putPreferencesV2(update)
- return response.data.preferences
+ mutationFn: async (update: NotificationSettingsUpdate) => {
+ const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
+ await Promise.all([
+ hasUpdates(appUpdate)
+ ? agent.app.bsky.notification.putPreferencesV2(appUpdate)
+ : undefined,
+ hasUpdates(chatUpdate)
+ ? agent.chat.bsky.notification.putPreferences(chatUpdate, {
+ headers: DM_SERVICE_HEADERS,
+ encoding: 'application/json',
+ })
+ : undefined,
+ ])
},
onMutate: update => {
optimisticUpdateNotificationSettings(queryClient, update)
},
onError: 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`, {
type: 'error',
})
@@ -55,13 +130,62 @@ export function useNotificationSettingsUpdateMutation() {
function optimisticUpdateNotificationSettings(
queryClient: QueryClient,
- update: Partial,
+ update: NotificationSettingsUpdate,
) {
- queryClient.setQueryData(
- RQKEY,
- (old?: AppBskyNotificationDefs.Preferences) => {
- if (!old) return old
- return {...old, ...update}
- },
- )
+ const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
+
+ if (hasUpdates(appUpdate)) {
+ queryClient.setQueryData(
+ RQKEY_APP,
+ (old?: AppNotificationSettingsPreferences) => {
+ if (!old) return old
+ 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 {
+ const {chat: _ignoredChat, ...appPreferences} = preferences
+ return appPreferences
+}
+
+function chatPreferencesForSettings(
+ preferences: ChatBskyNotificationDefs.Preferences,
+): Pick {
+ 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
}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0025a69d51..92b21ac52c 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -73,6 +73,7 @@ import {
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
@@ -387,6 +388,12 @@ export const ComposePost = ({
const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => {
const abortController = new AbortController()
+ const telemetry = createVideoTelemetry({
+ asset,
+ signal: abortController.signal,
+ metric: ax.metric,
+ })
+ telemetry.picked()
composerDispatch({
type: 'update_post',
postId: postId,
@@ -394,6 +401,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
+ telemetry,
},
})
void processVideo(
@@ -412,7 +420,7 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
- ax.metric,
+ telemetry,
)
},
[i18n, agent, currentDid, composerDispatch, ax.metric],
@@ -495,6 +503,12 @@ export const ComposePost = ({
// Start video processing using existing flow
const abortController = new AbortController()
+ const telemetry = createVideoTelemetry({
+ asset,
+ signal: abortController.signal,
+ metric: ax.metric,
+ })
+ telemetry.picked()
composerDispatch({
type: 'update_post',
postId,
@@ -502,6 +516,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
+ telemetry,
},
})
@@ -560,7 +575,7 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
- ax.metric,
+ telemetry,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
@@ -981,6 +996,15 @@ export const ComposePost = ({
})
).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
* ok, because the post _was_ actually published above.
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 35e1706007..fc8b341270 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -8,6 +8,7 @@ import {
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
+import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type SelfLabel} from '#/lib/moderation'
import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
@@ -88,6 +89,7 @@ export type PostAction =
type: 'embed_add_video'
asset: ImagePickerAsset
abortController: AbortController
+ telemetry: VideoTelemetry
}
| {type: 'embed_remove_video'}
| {type: 'embed_update_video'; videoAction: VideoAction}
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
if (!prevMedia) {
nextMedia = {
type: 'video',
- video: createVideoState(action.asset, action.abortController),
+ video: createVideoState(
+ action.asset,
+ action.abortController,
+ action.telemetry,
+ ),
}
}
return {
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index 76593d50de..6737d1f628 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -11,12 +11,12 @@ import {
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
+import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
-import {type AnalyticsContextType} from '#/analytics'
type CaptionsTrack = {lang: string; file: File}
@@ -65,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
video: undefined,
jobId: undefined,
pendingPublish: undefined,
+ telemetry: undefined,
altText: '',
captions: [],
})
@@ -80,6 +81,7 @@ type ErrorState = {
jobId: string | null
error: string
pendingPublish?: undefined
+ telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -92,6 +94,7 @@ type CompressingState = {
video?: undefined
jobId?: undefined
pendingPublish?: undefined
+ telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -104,6 +107,7 @@ type UploadingState = {
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
+ telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -117,6 +121,7 @@ type ProcessingState = {
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
+ telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -129,6 +134,7 @@ type DoneState = {
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef}
+ telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -143,12 +149,14 @@ export type VideoState =
export function createVideoState(
asset: ImagePickerAsset,
abortController: AbortController,
+ telemetry: VideoTelemetry,
): CompressingState {
return {
status: 'compressing',
progress: 0,
abortController,
asset,
+ telemetry,
altText: '',
captions: [],
}
@@ -171,6 +179,7 @@ export function videoReducer(
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
+ telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -199,6 +208,7 @@ export function videoReducer(
abortController: state.abortController,
asset: state.asset,
video: action.video,
+ telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -214,6 +224,7 @@ export function videoReducer(
video: state.video,
jobId: action.jobId,
jobStatus: null,
+ telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -240,6 +251,7 @@ export function videoReducer(
pendingPublish: {
blobRef: action.blobRef,
},
+ telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -266,40 +278,21 @@ export async function processVideo(
did: string,
signal: AbortSignal,
i18n: I18n,
- metric: AnalyticsContextType['metric'],
+ telemetry: VideoTelemetry,
) {
let video: CompressedVideo | undefined
try {
+ telemetry.compressStarted()
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), 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) {
const message = getCompressErrorMessage(e, i18n)
if (message !== null) {
+ telemetry.compressFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -308,6 +301,15 @@ export async function processVideo(
}
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({
type: 'compressing_to_uploading',
video,
@@ -316,6 +318,7 @@ export async function processVideo(
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try {
+ telemetry.uploadStarted(video.size)
uploadResponse = await uploadVideo({
video,
agent,
@@ -329,6 +332,7 @@ export async function processVideo(
} catch (e) {
const message = getUploadErrorMessage(e, i18n)
if (message !== null) {
+ telemetry.uploadFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -339,6 +343,8 @@ export async function processVideo(
}
const jobId = uploadResponse.jobId
+ telemetry.uploadCompleted(jobId)
+ telemetry.processingStarted(jobId)
dispatch({
type: 'uploading_to_processing',
jobId,
@@ -377,6 +383,7 @@ export async function processVideo(
}
logger.error('Error processing video', {safeMessage: e})
+ telemetry.processingFailed(e)
dispatch({
type: 'to_error',
error: i18n._(msg`Video failed to process`),
@@ -386,6 +393,7 @@ export async function processVideo(
}
if (blob) {
+ telemetry.processingCompleted()
dispatch({
type: 'to_done',
blobRef: blob,
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 8650e99b89..717921a092 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -242,12 +242,18 @@ export function BottomBar({navigation}: BottomTabBarProps) {
accessibilityLabel={l`Chat`}
accessibilityHint={
!aa.flags.chatDisabled && numUnreadMessages.count > 0
- ? l({
- message: plural(numUnreadMessages.numUnread ?? 0, {
- one: '# unread item',
- other: '# unread items',
- }),
- })
+ ? numUnreadMessages.numUnread?.includes('+')
+ ? 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, {
+ one: '# unread item',
+ other: '# unread items',
+ }),
+ })
: ''
}
/>
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 189a9fafcf..b36ba52ce1 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -27,6 +27,10 @@ export function DesktopSearch() {
if (query.length) setActive(true)
}
+ const onBlur = () => {
+ setActive(false)
+ }
+
const onChangeText = (text: string) => {
setQuery(text)
if (!active) {
@@ -64,6 +68,7 @@ export function DesktopSearch() {
hotkey
value={query}
onFocus={onFocus}
+ onBlur={onBlur}
onChangeText={onChangeText}
onClearText={onClearText}
onSubmitEditing={onSubmit}