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