Merge main into native-video-compress

Drops the parallel composer:video:probe* events added in 127d0288 in favor
of main's video:upload:* funnel from #10991. Reshapes the new module's
compressVideo to set passthroughReason on every skip branch so it conforms
to the VideoCompressSkipReason contract, including a new 'below-thresholds'
reason for the shouldCompress passthrough. Probe data wiring into the
funnel comes in a follow-up commit.
This commit is contained in:
vineyardbovines
2026-06-25 18:32:47 -04:00
37 changed files with 1477 additions and 617 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
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 && \
-16
View File
@@ -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
+2 -2
View File
@@ -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",
+78 -69
View File
@@ -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':
+96 -17
View File
@@ -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
}
}
+93
View File
@@ -0,0 +1,93 @@
import {Pressable} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const positionStyles = {
'top-left': {
top: a.p_xs.padding,
left: a.p_xs.padding,
},
'top-right': {
top: a.p_xs.padding,
right: a.p_xs.padding,
},
'bottom-left': {
bottom: a.p_xs.padding,
left: a.p_xs.padding,
},
'bottom-right': {
bottom: a.p_xs.padding,
right: a.p_xs.padding,
},
}
export function AltBadgeWithDialog({
text,
position,
}: {
text: string
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}) {
const t = useTheme()
const {t: l} = useLingui()
const large = useLargeAltBadgeEnabled()
const control = Prompt.usePromptControl()
const pos = position ? [a.absolute, positionStyles[position]] : {}
return (
<>
<Pressable
testID="altBadgeWithDialogButton"
accessibilityRole="button"
accessibilityLabel={l`Show alt text`}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={s => [
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
large && {
padding: 6,
},
{
opacity: 0.8,
},
pos,
s.hovered || s.pressed
? [
{
opacity: 1,
},
]
: [],
]}>
<Text
accessible={false}
style={[a.font_bold, large ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</Pressable>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Cancel cta={l`Close`} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
+5 -3
View File
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import {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],
}),
]}>
<ButtonIcon icon={EarthIcon} size="md" />
<Select.ValueText
placeholder={_(msg`Select an app language`)}
style={[t.atoms.text_contrast_medium]}
@@ -68,7 +70,7 @@ export function AppLanguageDropdown() {
<Select.Content
label={_(msg`Select language`)}
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.Item value={value} label={label} style={web([a.pointer])}>
<Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
@@ -66,19 +66,7 @@ export function GifEmbed({
a.overflow_hidden,
{backgroundColor: t.palette.black},
]}>
<View
style={[
a.absolute,
/*
* Aspect ratio was being clipped weirdly on web -esb
*/
{
top: -2,
bottom: -2,
left: -2,
right: -2,
},
]}>
<View style={[a.absolute, a.inset_0]}>
<MediaInsetBorder />
<GifPresentationControls
onPress={onPress}
@@ -1,18 +1,13 @@
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {ActivityIndicator, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -29,6 +24,7 @@ export function GifPresentationControls({
}) {
const {_} = useLingui()
const t = useTheme()
const largeBadge = useLargeAltBadgeEnabled()
return (
<>
@@ -64,74 +60,41 @@ export function GifPresentationControls({
]}
/>
)}
<View style={styles.gifBadgeContainer}>
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
<Trans>GIF</Trans>
</Text>
<View
style={[
a.absolute,
a.flex_row,
a.z_10,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeBadge && {
gap: 4,
},
]}>
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
largeBadge && {
padding: 6,
},
{
opacity: 0.8,
},
]}>
<Text style={[a.font_bold, largeBadge ? a.text_xs : {fontSize: 8}]}>
<Trans>GIF</Trans>
</Text>
</View>
{altText && <AltBadgeWithDialog text={altText} />}
</View>
{altText && <AltBadge text={altText} />}
</>
)
}
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
gifBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
left: 6,
bottom: 6,
zIndex: 2,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
},
})
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {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}
/>
) : (
<VideoPresentationControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
<>
<VideoPresentationControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
{embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
</>
)}
<MediaInsetBorder />
<KeepAwake enabled={isPlaying} />
@@ -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<Error | null>(null)
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
</figcaption>
)}
</figure>
{!isFullscreen && !isGif && embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
<Controls
videoRef={videoRef}
hlsRef={hlsRef}
@@ -88,7 +95,7 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
isGif={isGif}
altText={embed.alt}
updateCuePositions={updateCuePositions}
/>
@@ -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<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
name: NotificationSettingsPreferenceName
syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode
subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit<NotificationSettingsDialogProps, 'icon'>) {
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({
<PreferenceControls
name={name}
syncOthers={syncOthers}
preference={preferences?.[name]}
preference={preference}
allowDisableInApp={allowDisableInApp}
/>
)}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
<Dialog.Close />
{IS_NATIVE && (
<Button
color="secondary"
color="primary"
size="large"
label={l`Close dialog`}
onPress={() => control.close()}
+11 -14
View File
@@ -44,12 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme, utils} from '#/alf'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
@@ -834,6 +835,7 @@ function ReplyQuote({
}) {
const t = useTheme()
const {t: l} = useLingui()
const getReplyPreviewText = useReplyPreviewText()
const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did),
@@ -857,22 +859,15 @@ function ReplyQuote({
let text: string
let subtle = false
if (isBlocked) {
text = l`Blocked message hidden`
text = l({
message: '(blocked message hidden)',
comment: 'A reply summary in chat',
})
subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
text = replyTo.text
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`
}
}
;({text, subtle} = getReplyPreviewText(replyTo))
} else {
text = l`Deleted message`
text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
subtle = true
}
@@ -888,6 +883,8 @@ function ReplyQuote({
a.mb_xs,
a.rounded_md,
a.p_sm,
// The padding above is a little loose, so we tighten it up here.
{paddingTop: tokens.space.sm - 2},
a.flex_col,
a.align_start,
a.border,
+112
View File
@@ -0,0 +1,112 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedRecord,
type ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
/**
* Describes the embed of a quoted message that has no text of its own, so the
* reply preview can show what was shared instead of a generic placeholder. For
* a link card we surface the URI directly; otherwise we classify the quoted
* record (post/feed/list/etc.) so the caller can render a translated label.
*/
type ReplyEmbedSummary =
| {type: 'external'; uri: string}
| {type: 'post'}
| {type: 'unknown'}
function summarizeReplyEmbed(
embed: ChatBskyConvoDefs.MessageView['embed'],
): ReplyEmbedSummary {
if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
const {record} = embed
if (AppBskyEmbedRecord.isViewRecord(record)) {
const inner = record.embeds?.[0]
if (AppBskyEmbedExternal.isView(inner)) {
return {type: 'external', uri: inner.external.uri}
}
return {type: 'post'}
}
return {type: 'unknown'}
}
/**
* Returns a formatter that computes the preview text for a message being quoted
* in a reply, shared between the staged-reply composer and the sent reply
* bubble so the two stay in sync. When the message has its own text we use it
* verbatim; otherwise we summarize the embed.
*
* `subtle` indicates the text is a placeholder (not real message content), so
* callers can render it in a muted/italic style.
*/
export function useReplyPreviewText(): (
message: ChatBskyConvoDefs.MessageView,
) => {text: string; subtle: boolean} {
const {t: l} = useLingui()
return (message: ChatBskyConvoDefs.MessageView) => {
const text = message.text
if (text.trim()) {
return {text, subtle: false}
}
if (ChatBskyEmbedJoinLink.isView(message.embed)) {
const {joinLinkPreview} = message.embed
if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return {
text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
subtle: true,
}
}
if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(disabled chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(invalid chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
return {
text: l({
message: '(chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
const summary = summarizeReplyEmbed(message.embed)
switch (summary.type) {
case 'external':
return {text: toShortUrl(summary.uri), subtle: true}
case 'post':
return {
text: l({
message: '(quoted post)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
default:
return {
text: l({message: '(no text)', comment: 'A reply summary in chat'}),
subtle: true,
}
}
}
}
+11 -17
View File
@@ -2,7 +2,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {Plural} from '@lingui/react/macro'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
@@ -60,23 +60,17 @@ export function LabelsOnMe({
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{type === 'account' ? (
<Trans>
<Plural
value={labels.length}
one="# label has"
other="# labels have"
/>{' '}
been placed on this account
</Trans>
<Plural
value={labels.length}
one="# account label"
other="# account labels"
/>
) : (
<Trans>
<Plural
value={labels.length}
one="# label has"
other="# labels have"
/>{' '}
been placed on this content
</Trans>
<Plural
value={labels.length}
one="# content label"
other="# content labels"
/>
)}
</ButtonText>
</Button>
+38 -40
View File
@@ -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
+10 -13
View File
@@ -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<CompressedVideo> {
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',
}
}
+19 -7
View File
@@ -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<CompressedVideo['passthroughReason']> | null =
null
if (isGif) {
fallbackReason = 'gif'
} else if (!hasCodecs) {
fallbackReason = 'no-webcodecs'
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
fallbackReason = 'below-byte-threshold'
} else {
try {
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,
}
}
+271
View File
@@ -0,0 +1,271 @@
import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {nanoid} from 'nanoid/non-secure'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
// Identifies the active compression engine. Bumped when the engine swaps.
// Versions are intentionally hardcoded so a dependency bump shows up in
// analytics as a label change.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:mediabunny@1.25.3'
: 'native:react-native-compressor@1.13.0'
type Phase = 'compress' | 'upload' | 'processing'
function errorClass(e: unknown): string {
if (e instanceof Error) return e.name || 'Error'
return 'Unknown'
}
export type VideoTelemetry = {
readonly uploadId: string
readonly engine: string
picked: () => void
compressStarted: () => void
compressSkipped: (video: {
size: number
mimeType: string
skipReason: VideoCompressSkipReason
}) => void
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
processingCompleted: () => void
processingFailed: (e: unknown) => void
published: () => void
}
export function createVideoTelemetry({
asset,
signal,
metric,
}: {
asset: ImagePickerAsset
signal: AbortSignal
metric: MetricFn
}): VideoTelemetry {
const uploadId = nanoid()
const engine = COMPRESS_ENGINE
const startedAt = Date.now()
let phase: Phase | undefined
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let txnEnded = false
let abortBound = true
// Parent span: full selection->ready arc. Inactive so phase spans can be
// attached as children regardless of the current async context.
const txn = Sentry.startInactiveSpan({
name: 'video.upload',
op: 'video.upload',
attributes: {
uploadId,
engine,
'video.source.mime': asset.mimeType ?? 'unknown',
'video.source.bytes': asset.fileSize ?? 0,
'video.source.durationMs': asset.duration ?? 0,
'video.source.width': asset.width ?? 0,
'video.source.height': asset.height ?? 0,
},
})
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
function endPhaseSpan() {
if (!phaseSpan) return
phaseSpan.end()
phaseSpan = undefined
}
function enterPhase(next: Phase, spanName: string) {
endPhaseSpan()
phase = next
phaseStartedAt = Date.now()
phaseSpan = Sentry.withActiveSpan(txn, () =>
Sentry.startInactiveSpan({
name: spanName,
op: spanName,
attributes: {uploadId, engine},
}),
)
}
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
if (txnEnded) return
txnEnded = true
endPhaseSpan()
txn.setAttribute('outcome', outcome)
txn.end()
}
function detachAbort() {
if (!abortBound) return
abortBound = false
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
if (phase) {
metric('video:upload:abandoned', {
uploadId,
engine,
phase,
jobId,
elapsedInPhaseMs: Date.now() - phaseStartedAt,
})
}
endTxn('cancelled')
abortBound = false
}
signal.addEventListener('abort', onAbort, {once: true})
return {
uploadId,
engine,
picked() {
metric('video:upload:picked', {
uploadId,
engine,
sourceMimeType: asset.mimeType,
sourceBytes: asset.fileSize,
sourceDurationMs: asset.duration ?? undefined,
sourceWidth: asset.width,
sourceHeight: asset.height,
})
},
compressStarted() {
enterPhase('compress', 'video.compress')
metric('video:upload:compressStarted', {
uploadId,
engine,
sourceBytes: asset.fileSize,
})
},
compressSkipped({size, mimeType, skipReason}) {
metric('video:upload:compressSkipped', {
uploadId,
engine,
skipReason,
bytes: size,
mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressCompleted({size, mimeType}) {
metric('video:upload:compressCompleted', {
uploadId,
engine,
bytesIn: asset.fileSize,
bytesOut: size,
outputMimeType: mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressFailed(e) {
metric('video:upload:compressFailed', {
uploadId,
engine,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
uploadStarted(bytes) {
uploadBytes = bytes
enterPhase('upload', 'video.upload.transfer')
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
const bytes = uploadBytes ?? 0
metric('video:upload:uploadCompleted', {
uploadId,
engine,
jobId: id,
bytes,
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
})
endPhaseSpan()
phase = undefined
},
uploadFailed(e) {
metric('video:upload:uploadFailed', {
uploadId,
engine,
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
processingStarted(id) {
jobId = id
enterPhase('processing', 'video.processing')
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
},
processingCompleted() {
metric('video:upload:processingCompleted', {
uploadId,
engine,
jobId: jobId ?? '',
elapsedMs: Date.now() - phaseStartedAt,
})
// Upload pipeline is done; publish is a separate user action that
// fires its own event. Releases the parent span so its duration
// measures upload work, not idle composer time.
endTxn('ok')
detachAbort()
},
processingFailed(e) {
metric('video:upload:processingFailed', {
uploadId,
engine,
jobId: jobId ?? '',
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
published() {
metric('video:upload:published', {
uploadId,
engine,
jobId: jobId ?? '',
totalElapsedMs: Date.now() - startedAt,
})
},
}
}
+14
View File
@@ -1,7 +1,21 @@
// Why the compress engine returned the input unchanged. Used both as the
// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field
// on the `video:upload:compressSkipped` analytics event, so the two stay in
// sync.
export type VideoCompressSkipReason =
| 'gif'
| 'below-byte-threshold'
| 'below-thresholds'
| 'no-webcodecs'
| 'compress-error-fallback'
export type CompressedVideo = {
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
}
+118 -112
View File
@@ -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 "Were having network issues, try again"
msgid "Were 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 "Were so excited to have you join us!"
msgstr "Were so excited to have you join us!"
@@ -13559,7 +13565,7 @@ msgid "Who can verify?"
msgstr ""
#: 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 wont be able to rejoin unless youre 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 ""
+3 -10
View File
@@ -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 (
<Layout.Screen testID="messagesInboxScreen">
+103 -8
View File
@@ -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) {
<Divider style={{marginVertical: 10}} />
</>
) : null}
<View style={[a.px_xl, a.gap_lg]}>
<Text style={[a.pb_xs, a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>Notifications</Trans>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new messages`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatDialogControl.open()
}}>
<MessageIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New messages</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chat}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new message requests`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatRequestDialogControl.open()
}}>
<EnvelopeIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New message requests</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chatRequest}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
{IS_NATIVE && (
<>
<View style={[a.px_xl]}>
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</>
)}
<View style={[a.px_xl]}>
<Toggle.Item
label={l`Export my chat data`}
name="playSoundChat"
value={preferences.playSoundChat}
style={[a.flex_row, a.align_center, a.justify_between]}
onChange={() => {
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Export my chat data`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}
onPress={() => {
exportCarControl.open()
}}>
<CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
<Trans>Export my chat data</Trans>
</Text>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Toggle.Item>
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
</View>
</Layout.Content>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<ExportCarDialog control={exportCarControl} />
</Layout.Screen>
)
}
function NotificationPreferenceSubtitle({
preference,
isLoading,
isError,
}: {
preference?: NotificationSettingsPreference
isLoading: boolean
isError: boolean
}) {
const t = useTheme()
if (isError) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>Failed to load notification settings.</Trans>
</Text>
)
}
if (isLoading) {
return <Skele.Text style={[a.text_sm, {width: 120}]} />
}
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<SettingPreview preference={preference} />
</Text>
)
}
@@ -1,14 +1,12 @@
import {plural} from '@lingui/core/macro'
import {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({
<ButtonText style={[a.text_md, a.font_bold]}>
{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}
</ButtonText>
@@ -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 <MessageInputReplyInner replyTo={replyTo} />
}
function MessageInputReplyInner({
replyTo,
}: {
replyTo: ChatBskyConvoDefs.MessageView
}) {
const t = useTheme()
const {t: l} = useLingui()
const convo = useConvoActive()
const {clearReply} = useMessageReplies()
const getReplyPreviewText = useReplyPreviewText()
const {text, subtle} = getReplyPreviewText(replyTo)
const onRemove = () => {
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 (
<View
style={[
@@ -82,7 +82,7 @@ export function MessageInputReply() {
<Button
label={l`Cancel reply`}
onPress={onRemove}
style={[a.px_2xs]}
style={[a.px_2xs, {transform: [{translateX: 2}]}]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
@@ -0,0 +1,41 @@
import {Trans} from '@lingui/react/macro'
import type * as Dialog from '#/components/Dialog'
import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSettingsDialog'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
export function ChatNotificationDialogs({
chatControl,
chatRequestControl,
}: {
chatControl: Dialog.DialogControlProps
chatRequestControl: Dialog.DialogControlProps
}) {
return (
<>
<NotificationSettingsDialog
control={chatControl}
name="chat"
icon={MessageIcon}
titleText={<Trans>New messages</Trans>}
subtitleText={
<Trans>Get notifications when people send you messages.</Trans>
}
allowDisableInApp={false}
/>
<NotificationSettingsDialog
control={chatRequestControl}
name="chatRequest"
icon={EnvelopeIcon}
titleText={<Trans>New message requests</Trans>}
subtitleText={
<Trans>
Get notifications when people send you message requests.
</Trans>
}
allowDisableInApp={false}
/>
</>
)
}
@@ -1,9 +1,12 @@
import {useMemo} from 'react'
import {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<keyof AppBskyNotificationDefs.Preferences, '$type'>
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<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
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<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
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({
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
{allowDisableInApp && (
{allowDisableInApp && 'list' in preference && (
<Toggle.Item
label={l`Receive in-app notifications`}
name="list"
@@ -176,31 +173,17 @@ export function Inner({
/>
)}
</Toggle.Item>
{name === 'chat' ? (
<Toggle.Item
highlightRow
label={l`Accepted conversations`}
name="accepted">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`Accepted conversations`}
selected={selected}
/>
)}
</Toggle.Item>
) : (
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
)}
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
</View>
</Toggle.Group>
</>
@@ -0,0 +1,35 @@
import {useLingui} from '@lingui/react/macro'
import {type NotificationSettingsPreference} from '#/state/queries/notifications/settings'
export function SettingPreview({
preference,
}: {
preference?: NotificationSettingsPreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
}
if ('include' in preference) {
const list = 'list' in preference && preference.list
if (preference.include === 'all') {
if (list && preference.push) return l`In-app, push, everyone`
if (list) return l`In-app, everyone`
if (preference.push) return l`Push, everyone`
} else if (preference.include === 'follows') {
if (list && preference.push) return l`In-app, push, people you follow`
if (list) return l`In-app, people you follow`
if (preference.push) return l`Push, people you follow`
}
} else {
if (preference.list && preference.push) return l`In-app, push`
if (preference.list) return l`In-app`
if (preference.push) return l`Push`
}
return l`Off`
}
@@ -1,7 +1,6 @@
import {useEffect} from 'react'
import {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}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new messages`}
onPress={chatDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={MessageIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New messages</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chat} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new message requests`}
onPress={chatRequestDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={EnvelopeIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New message requests</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chatRequest} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for everything else`}
onPress={miscDialogControl.open}
@@ -333,6 +377,10 @@ export function NotificationSettingsScreen({}: Props) {
<Trans>Get notifications when people repost your reposts.</Trans>
}
/>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<NotificationSettingsDialog
control={miscDialogControl}
name="starterpackJoined"
@@ -350,46 +398,3 @@ export function NotificationSettingsScreen({}: Props) {
</Layout.Screen>
)
}
function SettingPreview({
preference,
}: {
preference?:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
} else {
if ('include' in preference) {
if (preference.include === 'all') {
if (preference.list && preference.push) {
return l`In-app, push, everyone`
} else if (preference.list) {
return l`In-app, everyone`
} else if (preference.push) {
return l`Push, everyone`
}
} else if (preference.include === 'follows') {
if (preference.list && preference.push) {
return l`In-app, push, people you follow`
} else if (preference.list) {
return l`In-app, people you follow`
} else if (preference.push) {
return l`Push, people you follow`
}
}
} else {
if (preference.list && preference.push) {
return l`In-app, push`
} else if (preference.list) {
return l`In-app`
} else if (preference.push) {
return l`Push`
}
}
}
return l`Off`
}
@@ -10,13 +10,12 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const
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()
@@ -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,
}
+145 -21
View File
@@ -1,4 +1,7 @@
import {type AppBskyNotificationDefs} from '@atproto/api'
import {
type AppBskyNotificationDefs,
type ChatBskyNotificationDefs,
} from '@atproto/api'
import {t} from '@lingui/core/macro'
import {
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<Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'>>
export type AppNotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
>
export type ChatNotificationSettingsPreferences = Pick<
ChatBskyNotificationDefs.Preferences,
'chat' | 'chatRequest'
>
export type NotificationSettingsPreferenceName = Exclude<
keyof NotificationSettingsPreferences,
'$type'
>
export type NotificationSettingsPreference =
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| ChatBskyNotificationDefs.ChatPreference
export function isChatPreferenceName(
name: NotificationSettingsPreferenceName,
): name is 'chat' | 'chatRequest' {
return name === 'chat' || name === 'chatRequest'
}
type NotificationSettingsUpdate = Partial<NotificationSettingsPreferences>
type AppNotificationSettingsUpdate = Partial<
Omit<AppBskyNotificationDefs.Preferences, '$type' | 'chat'>
>
type ChatNotificationSettingsUpdate =
Partial<ChatNotificationSettingsPreferences>
export function useNotificationSettingsQuery({
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<AppNotificationSettingsPreferences> => {
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<ChatNotificationSettingsPreferences> => {
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<AppBskyNotificationDefs.Preferences>,
) => {
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<AppBskyNotificationDefs.Preferences>,
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<AppBskyNotificationDefs.Preferences, 'chat'> {
const {chat: _ignoredChat, ...appPreferences} = preferences
return appPreferences
}
function chatPreferencesForSettings(
preferences: ChatBskyNotificationDefs.Preferences,
): Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'> {
return {
chat: preferences.chat,
chatRequest: preferences.chatRequest,
}
}
function splitNotificationSettingsUpdate(update: NotificationSettingsUpdate): {
appUpdate: AppNotificationSettingsUpdate
chatUpdate: ChatNotificationSettingsUpdate
} {
const {chat, chatRequest, $type: _type, ...appUpdate} = update
return {
appUpdate: appUpdate,
chatUpdate: {
...(chat !== undefined ? {chat} : {}),
...(chatRequest !== undefined ? {chatRequest} : {}),
},
}
}
function hasUpdates(update: object) {
return Object.keys(update).length > 0
}
+26 -2
View File
@@ -73,6 +73,7 @@ import {
} from '#/lib/constants'
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.
+7 -1
View File
@@ -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 {
+31 -23
View File
@@ -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,
+12 -6
View File
@@ -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',
}),
})
: ''
}
/>
+5
View File
@@ -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}