diff --git a/.gitignore b/.gitignore index 6dc2fe592b..015e13d3de 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ bskyweb/static/media/*.svg # superpowers plugin plans/specs — local-only workspace docs/superpowers/ +.claude/worktrees diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index afe47c2793..f0f948365c 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,11 +1,12 @@ import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants' import { downloadAndResize, type DownloadAndResizeOpts, - getResizedDimensions, } from '../../src/lib/media/manip' +import {getResizedDimensions} from '../../src/lib/media/util' const mockResizedImage = { path: 'file://resized-image.jpg', @@ -41,10 +42,8 @@ describe('downloadAndResize', () => { const opts: DownloadAndResizeOpts = { uri: 'https://example.com/image.jpg', - width: 100, - height: 100, + maxDimension: 2000, maxSize: 500000, - mode: 'cover', timeout: 10000, } @@ -60,9 +59,11 @@ describe('downloadAndResize', () => { // First time it gets called is to get dimensions expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) + // The mocked source image is 100x100, below maxDimension, so it is not + // downsized. expect(manipulateAsync).toHaveBeenCalledWith( expect.any(String), - [{resize: {height: opts.height, width: opts.width}}], + [{resize: {height: 100, width: 100}}], {format: SaveFormat.JPEG, compress: 1.0}, ) expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { @@ -73,10 +74,8 @@ describe('downloadAndResize', () => { it('should return undefined for invalid URI', async () => { const opts: DownloadAndResizeOpts = { uri: 'invalid-uri', - width: 100, - height: 100, + maxDimension: 2000, maxSize: 500000, - mode: 'cover', timeout: 10000, } @@ -90,13 +89,19 @@ describe('downloadAndResize', () => { width: 1200, height: 1000, } - const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + const resizedDimensionsOne = getResizedDimensions( + initialDimensionsOne, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) const initialDimensionsTwo = { width: 1000, height: 1200, } - const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + const resizedDimensionsTwo = getResizedDimensions( + initialDimensionsTwo, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) expect(resizedDimensionsOne).toEqual(initialDimensionsOne) expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) @@ -107,13 +112,19 @@ describe('downloadAndResize', () => { width: 3000, height: 1500, } - const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + const resizedDimensionsOne = getResizedDimensions( + initialDimensionsOne, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) const initialDimensionsTwo = { width: 2000, height: 4000, } - const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + const resizedDimensionsTwo = getResizedDimensions( + initialDimensionsTwo, + IMAGE_SIZE_CONFIG_2K_1MB.maxDimension, + ) expect(resizedDimensionsOne).toEqual({ width: 2000, diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts index 0b1b750281..23ffaa2875 100644 --- a/__tests__/lib/strings/url-helpers.test.ts +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -1,6 +1,7 @@ import {describe, expect, it} from '@jest/globals' import { + getChatInviteCodeFromUrl, isPossiblyAUrl, isTrustedUrl, linkRequiresWarning, @@ -178,3 +179,47 @@ describe('isTrustedUrl', () => { expect(output).toEqual(expected) }) }) + +describe('getChatInviteCodeFromUrl', () => { + type Case = [string, string | undefined] + + const cases: Case[] = [ + ['https://bsky.app/c/abcdefg', 'abcdefg'], + ['https://bsky.app/c/abcdefghij', 'abcdefghij'], + // http is not recognized as a bsky.app url + ['http://bsky.app/c/abcdefg', undefined], + ['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'], + ['https://bsky.app/c/abcdefg#section', 'abcdefg'], + ['/c/abcdefg', 'abcdefg'], + ['/c/abcdefg?utm=foo', 'abcdefg'], + ['/c/abcdefg#section', 'abcdefg'], + + // too short + ['https://bsky.app/c/abcdef', undefined], + ['/c/abcdef', undefined], + // too long + ['https://bsky.app/c/abcdefghijk', undefined], + ['/c/abcdefghijk', undefined], + // invalid characters + ['https://bsky.app/c/abc-def', undefined], + ['/c/abc def', undefined], + // trailing path + ['https://bsky.app/c/abcdefg/extra', undefined], + ['/c/abcdefg/extra', undefined], + // wrong path + ['https://bsky.app/profile/abcdefg', undefined], + ['https://bsky.app/c', undefined], + // wrong host + ['https://example.com/c/abcdefg', undefined], + // not a url, not a path + ['c/abcdefg', undefined], + ['abcdefg', undefined], + ['', undefined], + // malformed url + ['https://[invalid/c/abcdefg', undefined], + ] + + it.each(cases)('given input %p, returns %p', (input, expected) => { + expect(getChatInviteCodeFromUrl(input)).toEqual(expected) + }) +}) diff --git a/app.config.js b/app.config.js index e9bd6dac45..790ae490eb 100644 --- a/app.config.js +++ b/app.config.js @@ -349,7 +349,7 @@ module.exports = function (_config) { }, ], [ - '@mozzius/expo-dynamic-app-icon', + '@bsky.app/expo-dynamic-app-icon', { /** * Default set diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f2e677dc79..b8e65eb265 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -43,9 +43,6 @@ } }, "src/analytics/PassiveAnalytics.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "react-hooks/purity": { "count": 1 } @@ -124,11 +121,6 @@ "count": 1 } }, - "src/components/Button.tsx": { - "react-hooks/immutability": { - "count": 1 - } - }, "src/components/Composer/index.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -261,11 +253,6 @@ "count": 3 } }, - "src/components/Post/Embed/ExternalEmbed/index.tsx": { - "@typescript-eslint/no-floating-promises": { - "count": 1 - } - }, "src/components/Post/Embed/ImageEmbed.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -811,14 +798,6 @@ "count": 1 } }, - "src/lib/api/index.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 3 - } - }, "src/lib/async/retry.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/package.json b/package.json index c0670da958..a879182d9f 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.14", + "@bsky.app/expo-dynamic-app-icon": "^1.8.5", "@bsky.app/expo-guess-language": "^0.2.8", "@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-scroll-edge-effect": "^0.1.4", @@ -123,7 +124,6 @@ "@ipld/dag-cbor": "^9.2.7", "@lingui/core": "^5.9.2", "@lingui/react": "^5.9.2", - "@mozzius/expo-dynamic-app-icon": "^1.8.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/native": "^7.1.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 564e39f8f0..0ac8974cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@bsky.app/alf': specifier: ^0.1.14 version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@bsky.app/expo-dynamic-app-icon': + specifier: ^1.8.5 + version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bsky.app/expo-guess-language': specifier: ^0.2.8 version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -331,9 +334,6 @@ importers: '@lingui/react': specifier: ^5.9.2 version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0) - '@mozzius/expo-dynamic-app-icon': - specifier: ^1.8.0 - version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-native-async-storage/async-storage': specifier: 2.2.0 version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) @@ -1624,6 +1624,13 @@ packages: react: '*' react-native: '*' + '@bsky.app/expo-dynamic-app-icon@1.8.5': + resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==} + peerDependencies: + expo: ^52 || ^53 || ^54 + react: '*' + react-native: '*' + '@bsky.app/expo-guess-language@0.2.8': resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==} peerDependencies: @@ -2327,13 +2334,6 @@ packages: '@messageformat/parser@5.1.1': resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==} - '@mozzius/expo-dynamic-app-icon@1.8.1': - resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==} - peerDependencies: - expo: ^52 || ^53 || ^54 - react: '*' - react-native: '*' - '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -10446,6 +10446,14 @@ snapshots: react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-responsive: 10.0.1(react@19.1.0) + '@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@expo/image-utils': 0.8.12 + expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + xcode: 3.0.1 + '@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -11449,14 +11457,6 @@ snapshots: dependencies: moo: 0.5.3 - '@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': - dependencies: - '@expo/image-utils': 0.8.12 - expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - xcode: 3.0.1 - '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx index 34b5f2d275..c772ed09b8 100644 --- a/src/analytics/PassiveAnalytics.tsx +++ b/src/analytics/PassiveAnalytics.tsx @@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react' import {getCurrentState, onAppStateChange} from '#/lib/appState' import {useAnalytics} from '#/analytics' -import {Features, features} from '#/analytics/features' -import {IS_DEV, IS_TESTFLIGHT} from '#/env' /** * Tracks passive analytics like app foreground/background time. @@ -27,19 +25,19 @@ export function PassiveAnalytics() { }) } - if (IS_DEV || IS_TESTFLIGHT) { - const feats = Object.values(Features).reduce( - (acc, feat) => { - acc[feat] = features.evalFeature(feat) - return acc - }, - {} as Record, - ) - ax.logger.info('FEATURES', { - features: feats, - definitions: features.getFeatures(), - }) - } + // if (IS_DEV || IS_TESTFLIGHT) { + // const feats = Object.values(Features).reduce( + // (acc, feat) => { + // acc[feat] = features.evalFeature(feat) + // return acc + // }, + // {} as Record, + // ) + // ax.logger.info('FEATURES', { + // features: feats, + // definitions: features.getFeatures(), + // }) + // } }) return () => sub.remove() }, [ax]) diff --git a/src/analytics/metrics/client.ts b/src/analytics/metrics/client.ts index 2aab90f265..1ea0573dcd 100644 --- a/src/analytics/metrics/client.ts +++ b/src/analytics/metrics/client.ts @@ -67,10 +67,6 @@ export class MetricsClient> { } private async sendBatch(events: Event[], isRetry: boolean = false) { - logger.debug(`sendBatch: ${events.length}`, { - isRetry, - }) - try { const body = JSON.stringify({events}) if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 5f74595029..2d02bb832f 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -45,7 +45,7 @@ export type ButtonColor = | 'negative' | 'primary_subtle' | 'negative_subtle' -export type ButtonSize = 'tiny' | 'small' | 'large' +export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large' export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default' export type VariantProps = { /** @@ -136,7 +136,7 @@ export const Button = forwardRef( ( { children, - variant, + variant: variantProp, color, size, shape = 'default', @@ -160,7 +160,8 @@ export const Button = forwardRef( * If a `color` is set, then we want to use the existing codepaths for * "solid" buttons. This is to maintain backwards compatibility. */ - if (!variant && color) { + let variant: VariantProps['variant'] = variantProp + if (!variantProp && color) { variant = 'solid' } @@ -458,6 +459,12 @@ export const Button = forwardRef( paddingHorizontal: 24, gap: 6, }) + } else if (size === 'medium') { + baseStyles.push(a.rounded_full, { + paddingVertical: 9, + paddingHorizontal: 28, + gap: 5, + }) } else if (size === 'small') { baseStyles.push(a.rounded_full, { paddingVertical: 8, @@ -479,6 +486,13 @@ export const Button = forwardRef( borderRadius: 10, gap: 3, }) + } else if (size === 'medium') { + baseStyles.push({ + paddingVertical: 9, + paddingHorizontal: 16, + borderRadius: 8, + gap: 3, + }) } else if (size === 'small') { baseStyles.push({ paddingVertical: 8, @@ -505,6 +519,12 @@ export const Button = forwardRef( } else { baseStyles.push({height: 44, width: 44}) } + } else if (size === 'medium') { + if (shape === 'round') { + baseStyles.push({height: 33, width: 33}) + } else { + baseStyles.push({height: 33, width: 33}) + } } else if (size === 'small') { if (shape === 'round') { baseStyles.push({height: 33, width: 33}) @@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() { if (size === 'large') { baseStyles.push(a.text_md, a.font_medium) + } else if (size === 'medium') { + baseStyles.push(a.text_sm, a.font_medium) } else if (size === 'small') { baseStyles.push(a.text_sm, a.font_medium) } else if (size === 'tiny') { @@ -799,6 +821,7 @@ export function ButtonIcon({ size ?? (({ large: 'md', + medium: 'sm', small: 'sm', tiny: 'xs', }[buttonSize || 'small'] || 'sm') as Exclude< @@ -828,6 +851,7 @@ export function ButtonIcon({ */ const iconContainerSize = { large: 20, + medium: 17, small: 17, tiny: 15, }[buttonSize || 'small'] @@ -841,6 +865,7 @@ export function ButtonIcon({ if (buttonShape === 'default') { iconNegativeMargin = { large: -2, + medium: -2, small: -2, tiny: -1, }[buttonSize || 'small'] diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 8d3d39a749..51bf83309f 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -499,6 +499,7 @@ function TriggerClone({ accessibilityLabel={label} accessibilityHint={_(msg`The subject of the context menu`)} accessibilityIgnoresInvertColors={false} + cachePolicy="none" /> ) diff --git a/src/components/Lightbox/Lightbox.web.tsx b/src/components/Lightbox/Lightbox.web.tsx index 8883bfb5e5..0222aa3156 100644 --- a/src/components/Lightbox/Lightbox.web.tsx +++ b/src/components/Lightbox/Lightbox.web.tsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useRef, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' +import {Pressable, ScrollView, StyleSheet, View} from 'react-native' import {Image} from 'expo-image' import {Trans, useLingui} from '@lingui/react/macro' import {FocusGuards, FocusScope} from 'radix-ui/internal' @@ -226,17 +226,21 @@ function LightboxGallery({ )} {img.alt ? ( - + ]} + scrollEnabled={isAltExpanded} + contentContainerStyle={[a.px_4xl, a.py_2xl]}> - + ) : null} {imgs.length > 1 && (
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({ padding: 16, boxSizing: 'border-box', }, + altScroll: { + // Size to content like the View it replaced, rather than filling the + // column via ScrollView's default flexGrow. + flexGrow: 0, + flexShrink: 0, + // @ts-ignore web-only -sfn + maxHeight: '50vh', + }, menuBtn: { top: 20, left: 20, diff --git a/src/components/Lightbox/chrome/Footer.tsx b/src/components/Lightbox/chrome/Footer.tsx index a20c80df3d..0a708c7545 100644 --- a/src/components/Lightbox/chrome/Footer.tsx +++ b/src/components/Lightbox/chrome/Footer.tsx @@ -1,6 +1,9 @@ import {useRef} from 'react' import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' import {BlurView} from 'expo-blur' import {useLingui} from '@lingui/react/macro' @@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) { const {t: l} = useLingui() const t = useTheme() const insets = useSafeAreaInsets() + const {height: screenHeight} = useSafeAreaFrame() const isMomentumScrolling = useRef(false) if (!altText) return null + // Cap the overlay height so long alt text - or text enlarged by the OS via + // Dynamic Type / font scaling - scrolls within the overlay instead of growing + // past the top of the screen. Leaves the upper half clear for the header. + const maxHeight = screenHeight / 2 + return ( { isMomentumScrolling.current = true diff --git a/src/components/Lightbox/chrome/PagerDots.tsx b/src/components/Lightbox/chrome/PagerDots.tsx index a5fea5d4de..ea3cbc7f17 100644 --- a/src/components/Lightbox/chrome/PagerDots.tsx +++ b/src/components/Lightbox/chrome/PagerDots.tsx @@ -1,6 +1,5 @@ import {StyleSheet, View} from 'react-native' - -import {atoms as a} from '#/alf' +import {BlurView} from 'expo-blur' type Props = { count: number @@ -14,26 +13,38 @@ const GAP = 5 export function PagerDots({count, activeIndex}: Props) { if (count <= 1) return null return ( - - {Array.from({length: count}).map((_, i) => { - const isActive = i === activeIndex - return ( - - ) - })} + + + {Array.from({length: count}).map((_, i) => { + const isActive = i === activeIndex + return ( + + ) + })} + ) } const styles = StyleSheet.create({ - row: { + root: { + borderRadius: 999, + overflow: 'hidden', + }, + inner: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', gap: GAP, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: 'rgba(0, 0, 0, 0.5)', }, activeDot: { width: ACTIVE, diff --git a/src/components/Lightbox/chrome/PagerDots.web.tsx b/src/components/Lightbox/chrome/PagerDots.web.tsx new file mode 100644 index 0000000000..370917254f --- /dev/null +++ b/src/components/Lightbox/chrome/PagerDots.web.tsx @@ -0,0 +1,62 @@ +import {StyleSheet, View} from 'react-native' + +type Props = { + count: number + activeIndex: number +} + +const ACTIVE = 6 +const INACTIVE = 4 +const GAP = 5 + +export function PagerDots({count, activeIndex}: Props) { + if (count <= 1) return null + return ( + + {Array.from({length: count}).map((_, i) => { + const isActive = i === activeIndex + return ( + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: GAP, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + // @ts-expect-error web-only + backdropFilter: 'blur(8px)', + WebkitBackdropFilter: 'blur(8px)', + }, + activeDot: { + width: ACTIVE, + height: ACTIVE, + borderRadius: ACTIVE / 2, + }, + inactiveDot: { + width: INACTIVE, + height: INACTIVE, + borderRadius: INACTIVE / 2, + }, + active: { + backgroundColor: '#fff', + }, + inactive: { + backgroundColor: 'rgba(255, 255, 255, 0.4)', + }, +}) diff --git a/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx index c0bb99c89e..19373cbe1a 100644 --- a/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx +++ b/src/components/Lightbox/pager/ImageItem/ImageItem.ios.tsx @@ -246,6 +246,7 @@ const ImageItem = ({ } } cachePolicy="memory" + useAppleWebpCodec /> diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 1c8836a0fd..7b8815f276 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -32,6 +32,7 @@ import Animated, { withSpring, type WithSpringConfig, } from 'react-native-reanimated' +import {Image} from 'expo-image' import * as ScreenOrientation from 'expo-screen-orientation' import {type Dimensions} from '#/lib/media/types' @@ -136,6 +137,9 @@ export default function ImageViewRoot({ 'worklet' thumbRects.set({}) })() + requestIdleCallback(() => { + void Image.clearMemoryCache() + }) }, [thumbRects]) useAnimatedReaction( diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index de07a03b23..034094556c 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -53,31 +53,32 @@ export function Embed({ ) } else if (e.type === 'gallery') { // Notification/DM preview is a narrow inline strip; cap at 4 tiles so - // a 10-image gallery doesn't blow out the row width. - return ( - - {e.view.items - .filter(AppBskyEmbedGallery.isViewImage) - .slice(0, 4) - .map(item => { - const image: AppBskyEmbedImages.ViewImage = { - thumb: item.thumbnail, - fullsize: item.fullsize, - alt: item.alt, - aspectRatio: item.aspectRatio, - } - return peekable ? ( - - ) : ( - - ) - })} - - ) + // a 10-image gallery doesn't blow out the row width. Single pass instead + // of filter().slice().map() so we stop at 4 viewable items rather than + // walking every item in a 10-image gallery. + const tiles: React.ReactNode[] = [] + for (const item of e.view.items) { + if (tiles.length >= 4) break + if (!AppBskyEmbedGallery.isViewImage(item)) continue + if (peekable) { + const image: AppBskyEmbedImages.ViewImage = { + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + } + tiles.push() + } else { + tiles.push( + , + ) + } + } + return {tiles} } else if (e.type === 'link') { if (!e.view.external.thumb) return null if (!isGifEmbed(e.view.external.uri)) return null @@ -160,6 +161,7 @@ export function ImageItem({ contentFit="cover" accessible={true} accessibilityIgnoresInvertColors + useAppleWebpCodec /> {children} diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx new file mode 100644 index 0000000000..f50085426f --- /dev/null +++ b/src/components/Post/Embed/ChatInviteEmbed.tsx @@ -0,0 +1,48 @@ +import {type StyleProp, type ViewStyle} from 'react-native' +import {type AppBskyEmbedExternal} from '@atproto/api' + +import {atoms as a} from '#/alf' +import * as ChatInvite from '#/components/dms/ChatInvite' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed' + +/** + * Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g. + * a `bsky.app/c/` link posted to the feed) as a join request card, + * falling back to a plain external embed if the invite can't be resolved. + */ +export function ChatInviteEmbed({ + code, + link, + onOpen, + style, +}: { + code: string + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp +}) { + return ( + + + + ) +} + +function ChatInviteEmbedBody({ + link, + onOpen, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp +}) { + const {error} = ChatInvite.useChatInvite() + + if (error) { + return + } + + return +} diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index 43bfbb4747..472c403060 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -59,7 +59,7 @@ export const ExternalEmbed = ({ const onShareExternal = useCallback(() => { if (link.uri && IS_NATIVE) { playHaptic('Heavy') - shareUrl(link.uri) + void shareUrl(link.uri) } }, [link.uri, playHaptic]) @@ -108,6 +108,7 @@ export const ExternalEmbed = ({ source={{uri: imageUri}} accessibilityIgnoresInvertColors loading="lazy" + useAppleWebpCodec /> ) : undefined} diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx new file mode 100644 index 0000000000..db7ec589bb --- /dev/null +++ b/src/components/Post/Embed/JoinRequestEmbed.tsx @@ -0,0 +1,113 @@ +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {Trans} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import * as ChatInvite from '#/components/dms/ChatInvite' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +const JOIN_REQUEST_EMBED_HEIGHT = 140 + +/** + * The "join request" presentation of a chat invite, used as a post embed (in + * feeds and the post composer). Composes the headless `ChatInvite` primitive: + * pass either a `code` to fetch by, or an already-resolved `preview` as the + * initial data to avoid a loading flash. + */ +export function JoinRequestEmbed({ + code, + preview, + style, + onOpen, +}: { + code?: string + preview?: ChatBskyGroupDefs.JoinLinkPreviewView + style?: StyleProp + onOpen?: () => void +}) { + const resolvedCode = code ?? preview?.code + if (!resolvedCode) return null + + return ( + + + + ) +} + +/** + * The context-consuming presentation (loading / no-longer-available / card + + * join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g. + * to add an error fallback) can render it without nesting another Root. + */ +export function JoinRequestEmbedBody({ + style, + onOpen, +}: { + style?: StyleProp + onOpen?: () => void +}) { + const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + + Chat invite link no longer available + + + ) + } + + return ( + + + + + ) +} diff --git a/src/components/Post/Embed/StandardSiteEmbed/index.tsx b/src/components/Post/Embed/StandardSiteEmbed/index.tsx index 70fce8ad77..c7b8abf88c 100644 --- a/src/components/Post/Embed/StandardSiteEmbed/index.tsx +++ b/src/components/Post/Embed/StandardSiteEmbed/index.tsx @@ -166,6 +166,7 @@ export const StandardSiteEmbed = ({ source={{uri: imageUri}} accessibilityIgnoresInvertColors loading="lazy" + useAppleWebpCodec /> ) : undefined} @@ -355,6 +356,7 @@ export function PublicationCard({ /> {view.description && ( - + {view.description} @@ -616,6 +618,7 @@ export function PublicationFooter({ /> ) } + const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri) + if (chatInviteCode) { + return ( + + + + ) + } return ( ) diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx index ed4306459c..318b130414 100644 --- a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx +++ b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx @@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() { alt={_( msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx index f7e4bf04c6..153130f8ec 100644 --- a/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx +++ b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx @@ -124,6 +124,7 @@ export function BookmarksAnnouncement() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/DraftsAnnouncement.tsx b/src/components/dialogs/nuxs/DraftsAnnouncement.tsx index 79488fe4d9..37b93bda70 100644 --- a/src/components/dialogs/nuxs/DraftsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/DraftsAnnouncement.tsx @@ -101,6 +101,7 @@ export function DraftsAnnouncement() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx index 8fd7d2d2c5..70f29ef157 100644 --- a/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx @@ -78,6 +78,7 @@ export function FindContactsAnnouncement() { alt={_( msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx index 85bc29a48f..cbe2555aac 100644 --- a/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx +++ b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx @@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() { alt={_( msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, )} + useAppleWebpCodec /> @@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() { alt={_( msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`, )} + useAppleWebpCodec /> diff --git a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx index 49c43a1e41..e389e11175 100644 --- a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx +++ b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx @@ -150,6 +150,7 @@ export function LiveNowBetaDialog() { 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', }), )} + useAppleWebpCodec /> diff --git a/src/components/dms/ChatInvite/Card.tsx b/src/components/dms/ChatInvite/Card.tsx new file mode 100644 index 0000000000..518371325e --- /dev/null +++ b/src/components/dms/ChatInvite/Card.tsx @@ -0,0 +1,90 @@ +import {View} from 'react-native' +import {Plural, Trans} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {ProfileBadges} from '#/components/ProfileBadges' +import {Text} from '#/components/Typography' +import {useChatInvite} from './Context' + +/** + * Presentational preview of a chat invite: member avatars, group name, member + * count, and owner. Reads the preview from `ChatInvite.Root` context. Renders + * nothing if there's no preview (use a fallback alongside it for that case). + */ +export function Card({size}: {size: 'large' | 'small'}) { + const t = useTheme() + const {preview} = useChatInvite() + + if (!preview) return null + + const ownerDisplayName = createSanitizedDisplayName(preview.owner) + const ownerHandle = sanitizeHandle(preview.owner.handle, '@') + const avatarProfiles = preview.convo?.members ?? [preview.owner] + + return ( + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By {ownerDisplayName} + + + + + {ownerHandle} + + + + + ) +} diff --git a/src/components/dms/ChatInvite/Context.tsx b/src/components/dms/ChatInvite/Context.tsx new file mode 100644 index 0000000000..53c7a6cc22 --- /dev/null +++ b/src/components/dms/ChatInvite/Context.tsx @@ -0,0 +1,47 @@ +import {createContext, useContext} from 'react' +import {type ChatBskyGroupDefs} from '@atproto/api' + +import {type ButtonColor} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' + +/** + * The derived state of the join/open action for a chat invite, computed once in + * `Root` and consumed by `JoinButton` (or any custom action UI). + */ +export type ChatInviteAction = { + label: string + accessibilityHint: string + icon: React.ComponentType + color: ButtonColor + /** + * Whether the action can be performed. False when the link is disabled, the + * chat is full, or the viewer doesn't meet the join rule. + */ + disabled: boolean + onPress: () => void + side: 'left' | 'right' +} + +export type ChatInviteContextValue = { + code: string + loading: boolean + error: boolean + preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined + /** + * The derived action descriptor. Undefined while loading or when there's no + * preview to act on. + */ + action: ChatInviteAction | undefined +} + +const ChatInviteContext = createContext(null) + +export function useChatInvite(): ChatInviteContextValue { + const ctx = useContext(ChatInviteContext) + if (!ctx) { + throw new Error('useChatInvite must be used within a ChatInvite.Root') + } + return ctx +} + +export const ChatInviteProvider = ChatInviteContext.Provider diff --git a/src/components/dms/ChatInvite/JoinButton.tsx b/src/components/dms/ChatInvite/JoinButton.tsx new file mode 100644 index 0000000000..0036391b0e --- /dev/null +++ b/src/components/dms/ChatInvite/JoinButton.tsx @@ -0,0 +1,42 @@ +import {type StyleProp, type ViewStyle} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useChatInvite} from './Context' + +/** + * The join/open action button for a chat invite. Reads the derived action from + * `ChatInvite.Root` context. Pass `onPress` to intercept (e.g. to close a + * surface before navigating); it runs before the default action. Renders + * nothing while loading or when there's no preview to act on. + */ +export function JoinButton({ + onPress, + style, +}: { + onPress?: () => void + style?: StyleProp +}) { + const {action} = useChatInvite() + + if (!action) return null + + return ( + + ) +} diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx new file mode 100644 index 0000000000..a9f2c54e3a --- /dev/null +++ b/src/components/dms/ChatInvite/Root.tsx @@ -0,0 +1,144 @@ +import {setStringAsync} from 'expo-clipboard' +import {type ChatBskyGroupDefs} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import {useSession} from '#/state/session' +import {type ButtonColor} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight' +import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink' +import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {useIntentDialogs} from '#/components/intents/IntentDialogs' +import * as Toast from '#/components/Toast' +import {type ChatInviteAction, ChatInviteProvider} from './Context' + +/** + * Headless data + state owner for a chat invite. Fetches the join link preview + * by code and derives the join/open action, exposing both via context for the + * composable parts (`Card`, `JoinButton`) or any custom UI to consume. + * + * Pass `initialPreview` when the preview is already known (e.g. a DM message + * embed already carries the resolved view) to avoid a loading flash. + */ +export function Root({ + code, + initialPreview, + currentConvoId, + children, +}: { + code: string + initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView + /** + * The convo this invite is being viewed within, if any. When the invite + * links to the same chat, the action becomes "Copy link" instead of + * open/join (you're already here). + */ + currentConvoId?: string + children: React.ReactNode +}) { + const {hasSession} = useSession() + const {t: l} = useLingui() + const navigation = useNavigation() + const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs() + + const {data, error, isPending} = useJoinLinkPreviewsQuery({ + codes: [code], + hasSession, + // Seed the cache with the already-resolved preview so we don't refetch. + initialData: initialPreview + ? {joinLinkPreviews: [initialPreview]} + : undefined, + }) + + const preview = data?.joinLinkPreviews[0] + const loading = isPending && !preview + + let action: ChatInviteAction | undefined + if (preview) { + const convoId = preview.convo?.id + const isFollowing = preview.owner.viewer?.following ?? false + const hasRequested = !convoId && preview.viewer?.requestedAt != null + + if (convoId && convoId === currentConvoId) { + // You're already in the chat this invite links to - offer to copy the + // link rather than open/join. + action = { + label: l`Copy link`, + accessibilityHint: l`Tap to copy this invite link`, + icon: LinkIcon, + side: 'left', + color: 'primary', + disabled: false, + onPress: () => { + void setStringAsync(`https://bsky.app/c/${preview.code}`) + Toast.show(l`Copied to clipboard`, {type: 'success'}) + }, + } + } else if (convoId) { + action = { + label: l`Open chat`, + accessibilityHint: l`Tap to open this group chat`, + icon: ArrowRightIcon, + side: 'right', + color: 'primary', + disabled: false, + onPress: () => { + navigation.push('MessagesConversation', {conversation: convoId}) + }, + } + } else { + let canJoin = true + let icon: React.ComponentType = JoinIcon + let label = preview.requireApproval ? l`Request to join` : l`Join` + let color: ButtonColor = 'primary' + if (preview.enabledStatus !== 'enabled') { + canJoin = false + icon = WarningIcon + label = l`Chat invite link no longer available` + color = 'secondary' + } else if (preview.memberCount >= preview.memberLimit) { + canJoin = false + icon = HandIcon + label = l`This chat is full` + color = 'secondary' + } else if (preview.joinRule === 'followedByOwner' && !isFollowing) { + canJoin = false + icon = HandIcon + label = l`Only people the chat owner follows can join` + color = 'secondary' + } else if (hasRequested) { + icon = CheckIcon + label = l`Requested` + color = 'secondary' + } + + action = { + label, + side: 'left', + accessibilityHint: preview.requireApproval + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately`, + icon, + color, + disabled: !canJoin, + onPress: () => { + setGroupChatJoinState({code: preview.code}) + groupChatJoinDialogControl.open() + }, + } + } + } + + return ( + + {children} + + ) +} diff --git a/src/components/dms/ChatInvite/index.tsx b/src/components/dms/ChatInvite/index.tsx new file mode 100644 index 0000000000..bcea3f7651 --- /dev/null +++ b/src/components/dms/ChatInvite/index.tsx @@ -0,0 +1,8 @@ +export {Card} from './Card' +export { + type ChatInviteAction, + type ChatInviteContextValue, + useChatInvite, +} from './Context' +export {JoinButton} from './JoinButton' +export {Root} from './Root' diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index ac6acb5891..f4f3cdacc3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -20,6 +20,7 @@ import { AppBskyEmbedRecord, type ChatBskyActorDefs, ChatBskyConvoDefs, + ChatBskyEmbedJoinLink, RichText as RichTextAPI, } from '@atproto/api' import {plural} from '@lingui/core/macro' @@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' +import {MessageItemInviteEmbed} from './MessageItemInviteEmbed' import {groupReactions} from './ReactionsDialog' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' @@ -185,8 +187,10 @@ let MessageItem = ({ const rt = new RichTextAPI({text: message.text, facets: message.facets}) - const hasEmbedAndText = - AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + const hasEmbed = + AppBskyEmbedRecord.isView(message.embed) || + ChatBskyEmbedJoinLink.isView(message.embed) + const hasEmbedAndText = hasEmbed && rt.text.length > 0 const targetBottomRadius = squaredBottomCorner ? SQUARED_BORDER_RADIUS @@ -427,6 +431,15 @@ let MessageItem = ({ squaredTopCorner={squaredTopCorner} /> )} + {ChatBskyEmbedJoinLink.isView(message.embed) && ( + + )} {rt.text.length > 0 && ( + isFromSelf: boolean + isGroupChat: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean +}): React.ReactNode => { + const t = useTheme() + const screen = useWindowDimensions() + const convo = useConvoActive() + + return ( + + + + + + + + + + + ) +} +MessageItemInviteEmbed = memo(MessageItemInviteEmbed) +export {MessageItemInviteEmbed} diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index 79b41311cb..182bfe661b 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -142,6 +142,7 @@ export function AutoSizedImage({ } }} loading="lazy" + useAppleWebpCodec /> diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index fe2b63e606..0ca46c0b67 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' +import {IS_ANDROID, IS_WEB} from '#/env' export * from './const' export * from './maybeApplyGalleryOffsetStyles' @@ -264,6 +264,9 @@ export function Gallery({ aria-label={l`Image gallery, ${images.length} images`} horizontal pagingEnabled={false} + // Disable Android's stretch overscroll, which can leave the carousel + // settled just off the left edge instead of aligned to x = 0 + overScrollMode={IS_ANDROID ? 'never' : 'auto'} showsHorizontalScrollIndicator={false} directionalLockEnabled nestedScrollEnabled @@ -340,6 +343,11 @@ export function Gallery({ marginLeft: -insetLeft, width, }, + // Prevent horizontal trackpad/wheel swipes from triggering the + // browser's back/forward overscroll-navigation gesture. Handles + // Chrome and Firefox; Safari is handled via the wheel listener in + // usePointerHandlers.web.ts since it ignores overscroll-behavior. + web({overscrollBehaviorX: 'contain'}), ]} contentContainerStyle={{ gap: ITEM_GAP, @@ -480,8 +488,38 @@ function GalleryImage({ height: e.source.height, }) }} + useAppleWebpCodec /> + {!hideBadges && imageCount > 1 ? ( + + + {index + 1}/{imageCount} + + + ) : null} + {(hasAlt || isCropped) && !hideBadges ? ( { + if (!IS_WEB_SAFARI) return + // Only act on predominantly-horizontal scrolls. Vertical-dominant events + // are page scroll and must not be swallowed. + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return + + e.preventDefault() + + // Cancel any in-progress settle tween so manual scrolling feels direct. + if (stopTween) { + stopTween() + stopTween = null + } + if (overscrollX !== 0) clearOverscroll() + + const maxScroll = el.scrollWidth - el.clientWidth + const next = Math.max(0, Math.min(el.scrollLeft + e.deltaX, maxScroll)) + scrollTo(next) + + // Keep the active index in sync so keyboard/lightbox stay correct, but + // only settle when it actually changes - onSettle moves focus, which we + // don't want to thrash on every wheel tick. + let accumulated = 0 + let index = 0 + for (let i = 0; i < imageCount; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (next < accumulated + w / 2) { + index = i + break + } + accumulated += w + if (i === imageCount - 1) index = i + } + if (index !== localIndex) { + localIndex = index + onSettle(index) + } + } + el.addEventListener('mousedown', onMouseDown) + el.addEventListener('wheel', onWheel, {passive: false}) window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) return () => { el.removeEventListener('mousedown', onMouseDown) + el.removeEventListener('wheel', onWheel) window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) if (stopTween) stopTween() diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index 114e82985c..640ecd8ba2 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -109,6 +109,7 @@ export function GalleryItem({ } }} loading="lazy" + useAppleWebpCodec /> diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx index f787a89c3a..8d235b9a07 100644 --- a/src/components/intents/GroupChatJoinDialog.tsx +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { const {data, error, isLoading} = useJoinLinkPreviewsQuery({ codes: code ? [code] : undefined, hasSession, + staleTime: 0, }) const {mutate: joinGroupChat, isPending: isJoinPending} = @@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { ) { errorMessage = l`The member limit has been reached.` } else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) { - errorMessage = l`You have been removed from this group.` + errorMessage = l`You have been previously removed from this group and can’t join it using this link.` } Toast.show(errorMessage) }, @@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { - Open chat + + Open chat + ) : ( @@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { } accessibilityHint={ joinLinkPreview.requireApproval - ? l`Request access to join this group chat` - : l`Join this group chat` + ? l`Tap to request access to join this group chat` + : l`Tap to join this group chat immediately` } size="large" color={buttonColor} diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx index 570441ecd0..e8944024ca 100644 --- a/src/components/verification/VerifierDialog.tsx +++ b/src/components/verification/VerifierDialog.tsx @@ -87,6 +87,7 @@ function Inner({ alt={_( msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, )} + useAppleWebpCodec /> diff --git a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx index df2bb17f83..9d2f948eb6 100644 --- a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx +++ b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx @@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({ style={[a.absolute, a.inset_0, a.w_full, a.h_full]} contentFit="cover" placeholderContentFit="cover" + useAppleWebpCodec /> setImageLoadError(false)} onError={() => setImageLoadError(true)} + useAppleWebpCodec /> )} {linkMeta && (!linkMeta.image || imageLoadError) && ( diff --git a/src/features/liveNow/components/LiveStatusDialog.tsx b/src/features/liveNow/components/LiveStatusDialog.tsx index 545e8ab943..9e916678f2 100644 --- a/src/features/liveNow/components/LiveStatusDialog.tsx +++ b/src/features/liveNow/components/LiveStatusDialog.tsx @@ -147,6 +147,7 @@ export function LiveStatus({ contentFit="cover" style={[a.absolute, a.inset_0]} accessibilityIgnoresInvertColors + useAppleWebpCodec /> { logger.debug(`Compressing image #${i}`) - const {path, width, height, mime} = await compressImage(image) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) logger.debug(`Uploading image #${i}`) const res = await uploadBlob(agent, path, mime) return { @@ -350,7 +355,10 @@ async function resolveMedia( const items: $Typed[] = await Promise.all( imagesDraft.map(async (image, i) => { logger.debug(`Compressing image #${i}`) - const {path, width, height, mime} = await compressImage(image) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) logger.debug(`Uploading image #${i}`) const res = await uploadBlob(agent, path, mime) return { @@ -455,6 +463,16 @@ async function resolveMedia( }, } } + if (resolvedLink.type === 'chat-invite' && resolvedLink.view) { + return { + $type: 'app.bsky.embed.external', + external: { + uri: resolvedLink.uri, + title: resolvedLink.view.name, + description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`, + }, + } + } } return undefined } @@ -498,6 +516,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise { } // Returns a transformed version of the object for use in DAG-CBOR. +// eslint-disable-next-line @typescript-eslint/no-explicit-any function prepareForHashing(v: any): any { // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // the API client will convert this for you but we're hashing in the client, @@ -520,9 +539,10 @@ function prepareForHashing(v: any): any { // Walk through plain objects if (isPlainObject(v)) { - const obj: any = {} + const obj: Record = {} let pure = true for (const key in v) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access let value = v[key] // `value` is undefined if (value === undefined) { @@ -541,6 +561,7 @@ function prepareForHashing(v: any): any { return v } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function isPlainObject(v: any): boolean { if (typeof v !== 'object' || v === null) { return false diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index f75cd74db2..ca0b342045 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -2,11 +2,12 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, type BskyAgent, + type ChatBskyGroupDefs, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {POST_IMG_MAX} from '#/lib/constants' +import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -16,6 +17,7 @@ import { } from '#/lib/strings/starter-pack' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, @@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = { view: AppBskyGraphDefs.StarterPackView } +type ResolvedChatInvite = { + type: 'chat-invite' + uri: string + code: string + view?: ChatBskyGroupDefs.JoinLinkPreviewView +} + export type ResolvedLink = | ResolvedExternalLink | ResolvedPostRecord | ResolvedFeedRecord | ResolvedListRecord | ResolvedStarterPackRecord + | ResolvedChatInvite export class EmbeddingDisabledError extends Error { constructor() { @@ -141,6 +151,19 @@ export async function resolveLink( view: res.data.list, } } + const chatInviteCode = getChatInviteCodeFromUrl(uri) + if (chatInviteCode) { + const res = await agent.chat.bsky.group.getJoinLinkPreviews( + {codes: [chatInviteCode]}, + {headers: DM_SERVICE_HEADERS}, + ) + return { + type: 'chat-invite', + uri, + code: chatInviteCode, + view: res.data.joinLinkPreviews[0], + } + } if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { const parsed = parseStarterPackUri(uri) if (!parsed) { @@ -261,10 +284,7 @@ export async function imageToThumb( try { const img = await downloadAndResize({ uri: imageUri, - width: POST_IMG_MAX.width, - height: POST_IMG_MAX.height, - mode: 'contain', - maxSize: POST_IMG_MAX.size, + ...IMAGE_SIZE_CONFIG_2K_1MB, timeout: 15e3, }) if (img) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a22700f880..13ae162a85 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -97,10 +97,14 @@ export const STAGING_FEEDS = [ `feedgen|${STAGING_DEFAULT_FEED('thevids')}`, ] -export const POST_IMG_MAX = { - width: 2000, - height: 2000, - size: 1000000, +export const IMAGE_SIZE_CONFIG_POSTS = { + maxDimension: 4000, + maxSize: 2000000, +} + +export const IMAGE_SIZE_CONFIG_2K_1MB = { + maxDimension: 2000, + maxSize: 1000000, } export const STAGING_LINK_META_PROXY = diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 2be799e261..84ab65af92 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -16,24 +16,21 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' -import {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' import {IS_ANDROID, IS_IOS} from '#/env' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {convertCdnPreset} from './util' +import {convertCdnPreset, getResizedDimensions} from './util' export async function compressIfNeeded( img: PickerImage, - maxSize: number = POST_IMG_MAX.size, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, ): Promise { if (img.size < maxSize) { return img } const resizedImage = await doResize(normalizePath(img.path), { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension, maxSize, }) const finalImageMovedPath = await moveToPermanentPath( @@ -49,9 +46,7 @@ export async function compressIfNeeded( export interface DownloadAndResizeOpts { uri: string - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number timeout: number } @@ -67,7 +62,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) try { - return await doResize(path, opts) + return await doResize(path, { + maxDimension: opts.maxDimension, + maxSize: opts.maxSize, + }) } finally { void safeDeleteAsync(path) } @@ -188,9 +186,7 @@ export function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -204,10 +200,13 @@ async function doResize( // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() // does not work for local files... const imageRes = await manipulateAsync(localUri, [], {}) - const newDimensions = getResizedDimensions({ - width: imageRes.width, - height: imageRes.height, - }) + const newDimensions = getResizedDimensions( + { + width: imageRes.width, + height: imageRes.height, + }, + opts.maxDimension, + ) let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -388,28 +387,6 @@ async function withTempFile( } } -export function getResizedDimensions(originalDims: { - width: number - height: number -}) { - if ( - originalDims.width <= POST_IMG_MAX.width && - originalDims.height <= POST_IMG_MAX.height - ) { - return originalDims - } - - const ratio = Math.min( - POST_IMG_MAX.width / originalDims.width, - POST_IMG_MAX.height / originalDims.height, - ) - - return { - width: Math.round(originalDims.width * ratio), - height: Math.round(originalDims.height * ratio), - } -} - async function downloadImage(uri: string, destName: string, timeout: number) { // Download to a temp path first, then rename with the correct extension // based on the response's mimeType. diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 8fa2b6a4fa..94732687ef 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,27 +1,28 @@ import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' +import { + blobToDataUri, + convertCdnPreset, + getDataUriSize, + getResizedDimensions, +} from './util' export async function compressIfNeeded( img: PickerImage, - maxSize: number, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, ): Promise { if (img.size < maxSize) { return img } return await doResize(img.path, { - width: img.width, - height: img.height, - mode: 'stretch', + maxDimension, maxSize, }) } export interface DownloadAndResizeOpts { uri: string - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number timeout: number } @@ -34,7 +35,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { clearTimeout(to) const dataUri = await blobToDataUri(resBody) - return await doResize(dataUri, opts) + return await doResize(dataUri, { + maxDimension: opts.maxDimension, + maxSize: opts.maxSize, + }) } export async function shareImageModal(_opts: {uri: string}) { @@ -70,9 +74,7 @@ export async function getImageDim(path: string): Promise { // = interface DoResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' + maxDimension: number maxSize: number } @@ -80,6 +82,9 @@ async function doResize( dataUri: string, opts: DoResizeOpts, ): Promise { + const sourceDims = await getImageDim(dataUri) + const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension) + let newDataUri let minQualityPercentage = 0 @@ -90,10 +95,10 @@ async function doResize( (maxQualityPercentage + minQualityPercentage) / 2, ) const tempDataUri = await createResizedImage(dataUri, { - width: opts.width, - height: opts.height, + width: newDimensions.width, + height: newDimensions.height, quality: qualityPercentage / 100, - mode: opts.mode, + mode: 'contain', }) if (getDataUriSize(tempDataUri) < opts.maxSize) { @@ -111,8 +116,8 @@ async function doResize( path: newDataUri, mime: 'image/jpeg', size: getDataUriSize(newDataUri), - width: opts.width, - height: opts.height, + width: newDimensions.width, + height: newDimensions.height, } } diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index f01217d2a5..7aaa69c47d 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -8,6 +8,7 @@ import ExpoImageCropTool, { type OpenCropperOptions, } from '@bsky.app/expo-image-crop-tool' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {compressIfNeeded} from './manip' import {type PickerImage} from './picker.shared' @@ -28,13 +29,16 @@ async function getFile() { throw new Error('Failed to get file info') } - return await compressIfNeeded({ - path: file, - mime: 'image/jpeg', - size: fileInfo.size, - width: 4288, - height: 2848, - }) + return await compressIfNeeded( + { + path: file, + mime: 'image/jpeg', + size: fileInfo.size, + width: 4288, + height: 2848, + }, + IMAGE_SIZE_CONFIG_2K_1MB, + ) } export async function openPicker(): Promise { diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index da0b306d4f..8db418f045 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -2,6 +2,31 @@ export function extractDataUriMime(uri: string): string { return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) } +export function getResizedDimensions( + originalDims: { + width: number + height: number + }, + maxDimension: number, +) { + if ( + originalDims.width <= maxDimension && + originalDims.height <= maxDimension + ) { + return originalDims + } + + const ratio = Math.min( + maxDimension / originalDims.width, + maxDimension / originalDims.height, + ) + + return { + width: Math.round(originalDims.width * ratio), + height: Math.round(originalDims.height * ratio), + } +} + // Fairly accurate estimate that is more performant // than decoding and checking length of URI export function getDataUriSize(uri: string): number { diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index b5c278dacf..fd1a5ef7d9 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,5 +1,5 @@ import {AtUri} from '@atproto/api' -import psl from 'psl' +import {parse} from 'psl' import TLDs from 'tlds' import {BSKY_SERVICE} from '#/lib/constants' @@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean { return false } +// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof. export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/ export function getChatInviteCodeFromUrl(url: string): string | undefined { @@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean { } export function splitApexDomain(hostname: string): [string, string] { - const hostnamep = psl.parse(hostname) + const hostnamep = parse(hostname) if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { return ['', hostname] } diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index b1fd0337d7..5830797c52 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -93,7 +93,7 @@ msgstr "{0, plural, one {# person} other {# people}}" #. placeholder {0}: reactions.length #. placeholder {1}: groupedReactions.map(g => g.value).join(' ') -#: src/components/dms/MessageItem.tsx:297 +#: src/components/dms/MessageItem.tsx:273 msgid "{0, plural, one {# person} other {# people}} reacted – {1}" msgstr "{0, plural, one {# person} other {# people}} reacted – {1}" @@ -122,7 +122,7 @@ msgstr "" #. How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes. #. placeholder {0}: view.readingTime -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:232 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:233 msgid "{0, plural, one {#m} other {#m}}" msgstr "{0, plural, one {#m} other {#m}}" @@ -211,7 +211,7 @@ msgid "{0} following" msgstr "" #. placeholder {0}: sanitizeHandle(profile.handle, '@') -#: src/components/dms/MessageItem.tsx:640 +#: src/components/dms/MessageItem.tsx:609 msgid "{0} is blocking you" msgstr "{0} is blocking you" @@ -269,7 +269,7 @@ msgstr "" #. placeholder {0}: createSanitizedDisplayName(memberSender) #. placeholder {1}: reaction.value -#: src/components/dms/MessageItem.tsx:292 +#: src/components/dms/MessageItem.tsx:268 msgid "{0} reacted {1}" msgstr "" @@ -314,8 +314,8 @@ msgstr "" #. placeholder {0}: sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), ) #. placeholder {0}: sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), ) -#: src/view/com/util/UserAvatar.tsx:595 -#: src/view/com/util/UserAvatar.tsx:613 +#: src/view/com/util/UserAvatar.tsx:596 +#: src/view/com/util/UserAvatar.tsx:614 msgid "{0}'s avatar" msgstr "" @@ -839,7 +839,7 @@ msgstr "" msgid "A new code has been sent" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:93 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:94 msgid "A new form of verification" msgstr "" @@ -897,7 +897,7 @@ msgid "Accept join request" msgstr "Accept join request" #. Accept a chat request -#: src/screens/Messages/components/RequestListItem.tsx:48 +#: src/screens/Messages/components/RequestListItem.tsx:51 msgid "Accept Request" msgstr "" @@ -999,7 +999,7 @@ msgctxt "toast" msgid "Account unmuted" msgstr "" -#: src/components/verification/VerifierDialog.tsx:99 +#: src/components/verification/VerifierDialog.tsx:100 msgid "Accounts with a scalloped blue check mark <0><1/> can verify others. These trusted verifiers are selected by Bluesky." msgstr "" @@ -1085,7 +1085,7 @@ msgstr "" msgid "Add App Password" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:166 +#: src/screens/Settings/AutomationLabelSettings.tsx:170 msgid "Add automation label to account" msgstr "Add automation label to account" @@ -1112,8 +1112,8 @@ msgstr "" msgid "Add members" msgstr "Add members" -#: src/components/moderation/ReportDialog/index.tsx:526 -#: src/components/moderation/ReportDialog/index.tsx:530 +#: src/components/moderation/ReportDialog/index.tsx:528 +#: src/components/moderation/ReportDialog/index.tsx:532 msgid "Add more details (optional)" msgstr "" @@ -1215,7 +1215,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:544 +#: src/components/moderation/ReportDialog/index.tsx:546 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1283,7 +1283,7 @@ msgid "alice@example.com" msgstr "" #. the default tab in the interests tab bar -#: src/components/dms/ReactionsDialog.tsx:284 +#: src/components/dms/ReactionsDialog.tsx:288 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:201 #: src/view/screens/Notifications.tsx:86 msgid "All" @@ -1291,7 +1291,7 @@ msgstr "" #. Tab label showing the total count of reactions on a chat message. #. placeholder {0}: tab.count -#: src/components/dms/ReactionsDialog.tsx:381 +#: src/components/dms/ReactionsDialog.tsx:385 msgid "All {0}" msgstr "All {0}" @@ -1388,9 +1388,9 @@ msgstr "" msgid "Already signed in as @{0}" msgstr "" -#: src/components/images/AutoSizedImage.tsx:203 -#: src/components/images/Gallery/index.tsx:531 -#: src/components/images/ImageLayoutGridItem.tsx:138 +#: src/components/images/AutoSizedImage.tsx:204 +#: src/components/images/Gallery/index.tsx:539 +#: src/components/images/ImageLayoutGridItem.tsx:139 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/photos/Gallery.tsx:209 @@ -1540,7 +1540,7 @@ msgstr "" msgid "An issue occurred, please try again." msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:120 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:121 msgid "An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name." msgstr "" @@ -1729,7 +1729,7 @@ msgstr "" msgid "Are you sure you want to delete the app password \"{0}\"?" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:223 +#: src/components/dms/MessageOverlays.tsx:167 msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." msgstr "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." @@ -1968,7 +1968,7 @@ msgstr "" msgid "Block accounts" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:143 +#: src/components/dms/AfterReportDialog.tsx:148 msgid "Block and delete" msgstr "Block and delete" @@ -1992,9 +1992,9 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:221 #: src/components/dms/AfterReportConversationDialog.tsx:224 -#: src/components/dms/AfterReportDialog.tsx:149 -#: src/components/dms/AfterReportDialog.tsx:184 -#: src/components/dms/AfterReportDialog.tsx:187 +#: src/components/dms/AfterReportDialog.tsx:154 +#: src/components/dms/AfterReportDialog.tsx:189 +#: src/components/dms/AfterReportDialog.tsx:192 msgid "Block user" msgstr "" @@ -2004,7 +2004,7 @@ msgctxt "button" msgid "Block user" msgstr "Block user" -#: src/components/dms/AfterReportDialog.tsx:180 +#: src/components/dms/AfterReportDialog.tsx:185 msgid "Block user and/or delete this conversation" msgstr "" @@ -2085,7 +2085,7 @@ msgstr "" msgid "Bluesky is more fun with friends" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:107 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:108 msgid "Bluesky is more fun with friends! Import your contacts to see who’s already here." msgstr "" @@ -2109,7 +2109,7 @@ msgstr "" msgid "Bluesky will not show your profile and posts to logged-out users. Other apps may not honor this request. This does not make your account private." msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:134 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:136 msgid "Bluesky will proactively verify notable and authentic accounts." msgstr "" @@ -2198,7 +2198,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(handle, '@') #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:845 +#: src/components/moderation/ReportDialog/index.tsx:847 #: src/screens/Messages/JoinRequest.tsx:166 #: src/screens/Search/components/StarterPackCard.tsx:107 #: src/screens/Search/Explore.tsx:971 @@ -2322,7 +2322,7 @@ msgstr "" msgid "Captions & alt text" msgstr "" -#: src/components/images/Gallery/index.tsx:256 +#: src/components/images/Gallery/index.tsx:263 msgid "carousel" msgstr "carousel" @@ -2355,7 +2355,7 @@ msgstr "" msgid "Change Handle" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:443 +#: src/components/moderation/ReportDialog/index.tsx:445 msgid "Change moderation service" msgstr "" @@ -2368,11 +2368,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:310 +#: src/components/moderation/ReportDialog/index.tsx:312 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:388 +#: src/components/moderation/ReportDialog/index.tsx:390 msgid "Change report reason" msgstr "" @@ -2643,7 +2643,7 @@ msgstr "Click here to view the invite link for this group chat" msgid "Click to open tag menu for {0}" msgstr "" -#: src/components/dms/MessageItem.tsx:559 +#: src/components/dms/MessageItem.tsx:528 msgid "Click to retry failed message" msgstr "" @@ -2657,15 +2657,15 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:233 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:239 #: src/components/dialogs/LanguageSelectDialog.tsx:359 -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:159 -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:168 -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:164 -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:172 -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:139 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:176 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:185 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:191 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:199 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:160 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:169 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:165 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:173 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:140 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:187 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:192 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:200 #: src/components/dialogs/SearchablePeopleList.tsx:340 #: src/components/dialogs/StarterPackDialog.tsx:187 #: src/components/dms/AddMembersFlow.tsx:367 @@ -2673,10 +2673,10 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:96 #: src/components/dms/AfterReportConversationDialog.tsx:247 #: src/components/dms/AfterReportConversationDialog.tsx:252 -#: src/components/dms/AfterReportDialog.tsx:89 #: src/components/dms/AfterReportDialog.tsx:94 -#: src/components/dms/AfterReportDialog.tsx:207 +#: src/components/dms/AfterReportDialog.tsx:99 #: src/components/dms/AfterReportDialog.tsx:212 +#: src/components/dms/AfterReportDialog.tsx:217 #: src/components/dms/EmojiPopup.android.tsx:59 #: src/components/dms/InitiateChatFlow.tsx:534 #: src/components/intents/GroupChatJoinDialog.tsx:204 @@ -2688,7 +2688,7 @@ msgstr "" #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:122 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:128 #: src/components/verification/VerificationsDialog.tsx:146 -#: src/components/verification/VerifierDialog.tsx:146 +#: src/components/verification/VerifierDialog.tsx:147 #: src/components/WhoCanReply.tsx:236 #: src/components/WhoCanReply.tsx:243 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:45 @@ -2724,7 +2724,7 @@ msgstr "" #: src/components/dialogs/LanguageSelectDialog.tsx:354 #: src/components/dialogs/NotificationSettingsDialog.tsx:94 #: src/components/verification/VerificationsDialog.tsx:138 -#: src/components/verification/VerifierDialog.tsx:139 +#: src/components/verification/VerifierDialog.tsx:140 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:36 msgid "Close dialog" msgstr "" @@ -2999,8 +2999,8 @@ msgstr "" msgid "Conversation deleted" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:144 -#: src/components/dms/AfterReportDialog.tsx:147 +#: src/components/dms/AfterReportDialog.tsx:149 +#: src/components/dms/AfterReportDialog.tsx:152 msgctxt "toast" msgid "Conversation deleted" msgstr "" @@ -3020,7 +3020,7 @@ msgstr "Conversation not found." msgid "Copied build version to clipboard" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:79 +#: src/components/dms/MessageContextMenu.tsx:71 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:272 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 @@ -3102,8 +3102,8 @@ msgstr "" msgid "Copy link to starter pack" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:169 -#: src/components/dms/MessageContextMenu.tsx:173 +#: src/components/dms/MessageContextMenu.tsx:152 +#: src/components/dms/MessageContextMenu.tsx:156 msgid "Copy message text" msgstr "" @@ -3160,7 +3160,7 @@ msgid "Could not follow all matches. {0}" msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:158 -#: src/components/dms/AfterReportDialog.tsx:134 +#: src/components/dms/AfterReportDialog.tsx:139 #: src/components/dms/LeaveConvoPrompt.tsx:34 msgid "Could not leave chat" msgstr "" @@ -3316,8 +3316,8 @@ msgstr "Create or modify an invite link for this group chat" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:707 -#: src/components/moderation/ReportDialog/index.tsx:752 +#: src/components/moderation/ReportDialog/index.tsx:709 +#: src/components/moderation/ReportDialog/index.tsx:754 msgid "Create report for {0}" msgstr "" @@ -3418,7 +3418,7 @@ msgstr "" msgid "Default icons" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:224 +#: src/components/dms/MessageOverlays.tsx:168 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 #: src/screens/Settings/AppPasswords.tsx:213 @@ -3459,15 +3459,15 @@ msgstr "" msgid "Delete contacts" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:146 -#: src/components/dms/AfterReportDialog.tsx:190 -#: src/components/dms/AfterReportDialog.tsx:193 +#: src/components/dms/AfterReportDialog.tsx:151 +#: src/components/dms/AfterReportDialog.tsx:195 +#: src/components/dms/AfterReportDialog.tsx:198 #: src/screens/Messages/components/RequestButtons.tsx:142 #: src/screens/Messages/components/RequestButtons.tsx:144 msgid "Delete conversation" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:184 +#: src/components/dms/MessageContextMenu.tsx:167 msgid "Delete for me" msgstr "" @@ -3476,11 +3476,11 @@ msgstr "" msgid "Delete list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:222 +#: src/components/dms/MessageOverlays.tsx:166 msgid "Delete message" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:181 +#: src/components/dms/MessageContextMenu.tsx:164 msgid "Delete message for me" msgstr "" @@ -3661,8 +3661,8 @@ msgstr "" msgid "Discard post?" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:261 -#: src/screens/Profile/components/GermButton.tsx:268 +#: src/screens/Profile/components/GermButton.tsx:262 +#: src/screens/Profile/components/GermButton.tsx:269 msgid "Disconnect Germ DM" msgstr "" @@ -3690,7 +3690,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: src/components/contacts/FindContactsBannerNUX.tsx:77 +#: src/components/contacts/FindContactsBannerNUX.tsx:78 msgid "Dismiss banner" msgstr "" @@ -3784,7 +3784,7 @@ msgstr "" #: src/components/dialogs/ServerInput.tsx:237 #: src/components/dialogs/ServerInput.tsx:239 #: src/components/dms/AfterReportConversationDialog.tsx:164 -#: src/components/dms/AfterReportDialog.tsx:140 +#: src/components/dms/AfterReportDialog.tsx:145 #: src/components/forms/DateField/index.tsx:104 #: src/components/forms/DateField/index.tsx:110 #: src/lib/media/picker.tsx:37 @@ -3805,7 +3805,7 @@ msgctxt "action" msgid "Done" msgstr "" -#: src/components/dms/MessageItem.tsx:463 +#: src/components/dms/MessageItem.tsx:432 msgid "Double tap or long press the message to add a reaction" msgstr "" @@ -3848,7 +3848,7 @@ msgstr "Download profile data" msgid "Doxxing" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:119 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:120 #: src/view/com/composer/drafts/DraftsButton.tsx:70 #: src/view/com/composer/drafts/DraftsButton.tsx:79 #: src/view/com/composer/drafts/DraftsListDialog.tsx:119 @@ -3917,7 +3917,7 @@ msgctxt "action" msgid "Edit" msgstr "" -#: src/view/com/util/UserAvatar.tsx:460 +#: src/view/com/util/UserAvatar.tsx:461 #: src/view/com/util/UserBanner.tsx:122 msgid "Edit avatar" msgstr "" @@ -4299,7 +4299,7 @@ msgstr "" msgid "Exit fullscreen" msgstr "" -#: src/components/Lightbox/chrome/Footer.tsx:65 +#: src/components/Lightbox/chrome/Footer.tsx:59 #: src/components/Lightbox/Lightbox.web.tsx:241 msgid "Expand alt text" msgstr "" @@ -4420,7 +4420,7 @@ msgid "Failed to accept join request" msgstr "Failed to accept join request" #: src/components/dms/ActionsWrapper.web.tsx:77 -#: src/components/dms/MessageContextMenu.tsx:119 +#: src/components/dms/MessageContextMenu.tsx:103 msgid "Failed to add emoji reaction" msgstr "" @@ -4465,7 +4465,7 @@ msgctxt "toast" msgid "Failed to delete chat" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:101 +#: src/components/dms/MessageOverlays.tsx:112 msgid "Failed to delete message" msgstr "" @@ -4482,7 +4482,7 @@ msgid "Failed to disable invite link" msgstr "Failed to disable invite link" #. placeholder {0}: error?.message -#: src/screens/Profile/components/GermButton.tsx:195 +#: src/screens/Profile/components/GermButton.tsx:196 msgid "Failed to disconnect Germ DM. Error: {0}" msgstr "" @@ -4593,7 +4593,7 @@ msgid "Failed to pin post" msgstr "" #. placeholder {0}: e?.message -#: src/screens/Profile/components/GermButton.tsx:163 +#: src/screens/Profile/components/GermButton.tsx:164 msgid "Failed to reconnect Germ DM. Error: {0}" msgstr "" @@ -4607,8 +4607,8 @@ msgid "Failed to remove data. {0}" msgstr "" #: src/components/dms/ActionsWrapper.web.tsx:73 -#: src/components/dms/MessageContextMenu.tsx:115 -#: src/components/dms/ReactionsDialog.tsx:171 +#: src/components/dms/MessageContextMenu.tsx:99 +#: src/components/dms/ReactionsDialog.tsx:175 msgid "Failed to remove emoji reaction" msgstr "" @@ -4833,11 +4833,11 @@ msgstr "" msgid "Finalizing" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:145 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:146 msgid "Finally!" msgstr "" -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:155 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:156 msgid "Finally! Keep track of posts that matter to you. Save them to revisit anytime." msgstr "" @@ -4885,7 +4885,7 @@ msgstr "" msgid "Find posts, users, and feeds on Bluesky" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:97 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:98 msgid "Find your friends" msgstr "" @@ -5191,20 +5191,20 @@ msgid "Generate new link" msgstr "Generate new link" #: src/screens/Profile/components/GermButton.tsx:86 -#: src/screens/Profile/components/GermButton.tsx:224 +#: src/screens/Profile/components/GermButton.tsx:225 msgid "Germ DM" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:182 +#: src/screens/Profile/components/GermButton.tsx:183 msgid "Germ DM disconnected" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:233 -#: src/screens/Profile/components/GermButton.tsx:238 +#: src/screens/Profile/components/GermButton.tsx:234 +#: src/screens/Profile/components/GermButton.tsx:239 msgid "Germ DM Link" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:159 +#: src/screens/Profile/components/GermButton.tsx:160 msgid "Germ DM reconnected" msgstr "" @@ -5268,7 +5268,7 @@ msgstr "" msgid "Get notified when {name} posts" msgstr "" -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:138 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:139 msgid "Get notified when someone posts" msgstr "" @@ -5279,7 +5279,7 @@ msgstr "" msgid "Get started" msgstr "" -#: src/components/MediaPreview.tsx:147 +#: src/components/MediaPreview.tsx:148 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:69 msgid "GIF" msgstr "" @@ -5413,8 +5413,8 @@ msgstr "Go to user’s profile" msgid "Going live is currently disabled for your account" msgstr "" -#: src/screens/Profile/components/GermButton.tsx:252 -#: src/screens/Profile/components/GermButton.tsx:257 +#: src/screens/Profile/components/GermButton.tsx:253 +#: src/screens/Profile/components/GermButton.tsx:258 msgid "Got it" msgstr "" @@ -5524,7 +5524,7 @@ msgid "Handle too long. Please try a shorter one." msgstr "" #: src/components/FeedCard.tsx:156 -#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:122 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:123 msgid "Happening now" msgstr "" @@ -5903,7 +5903,7 @@ msgid "Image" msgstr "" #. placeholder {0}: index + 1 -#: src/components/images/Gallery/index.tsx:436 +#: src/components/images/Gallery/index.tsx:443 msgid "Image {0}" msgstr "Image {0}" @@ -5914,7 +5914,7 @@ msgid "Image {0} of {1}" msgstr "" #. placeholder {0}: index + 1 -#: src/components/images/Gallery/index.tsx:421 +#: src/components/images/Gallery/index.tsx:428 msgid "Image {0} of {imageCount}" msgstr "Image {0} of {imageCount}" @@ -5929,17 +5929,17 @@ msgid "Image cache cleared, freed {0}" msgstr "" #. placeholder {0}: images.length -#: src/components/images/Gallery/index.tsx:257 +#: src/components/images/Gallery/index.tsx:264 msgid "Image gallery, {0} images" msgstr "Image gallery, {0} images" #. Image has been moderated and user has the option of showing it temporarily -#: src/features/liveNow/components/LiveStatusDialog.tsx:299 +#: src/features/liveNow/components/LiveStatusDialog.tsx:300 msgid "Image is hidden due to your moderation settings." msgstr "Image is hidden due to your moderation settings." #. Image has been moderated and is not visible to the user -#: src/features/liveNow/components/LiveStatusDialog.tsx:309 +#: src/features/liveNow/components/LiveStatusDialog.tsx:310 msgid "Image is unavailable." msgstr "Image is unavailable." @@ -5974,13 +5974,13 @@ msgstr "" msgid "Import contacts" msgstr "" -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:115 -#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:126 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:116 +#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:127 msgid "Import Contacts" msgstr "" #: src/components/contacts/FindContactsBannerNUX.tsx:33 -#: src/components/contacts/FindContactsBannerNUX.tsx:71 +#: src/components/contacts/FindContactsBannerNUX.tsx:72 msgid "Import contacts to find your friends" msgstr "" @@ -6093,7 +6093,7 @@ msgstr "" msgid "Invalid phone number" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:98 +#: src/components/moderation/ReportDialog/index.tsx:100 msgid "Invalid report subject" msgstr "" @@ -6311,7 +6311,7 @@ msgid "Latest" msgstr "" #: src/components/verification/VerificationsDialog.tsx:168 -#: src/components/verification/VerifierDialog.tsx:135 +#: src/components/verification/VerifierDialog.tsx:136 #: src/screens/Moderation/VerificationSettings.tsx:50 #: src/screens/Profile/Header/EditProfileDialog.tsx:346 #: src/screens/Settings/components/ChangeHandleDialog.tsx:215 @@ -6366,7 +6366,7 @@ msgid "Learn more about this warning" msgstr "" #: src/components/verification/VerificationsDialog.tsx:153 -#: src/components/verification/VerifierDialog.tsx:121 +#: src/components/verification/VerifierDialog.tsx:122 msgctxt "english-only-resource" msgid "Learn more about verification on Bluesky" msgstr "" @@ -6376,7 +6376,7 @@ msgstr "" msgid "Learn more about what is public on Bluesky." msgstr "" -#: src/screens/Profile/components/GermButton.tsx:211 +#: src/screens/Profile/components/GermButton.tsx:212 msgid "Learn more about your Germ DM link" msgstr "" @@ -6674,7 +6674,7 @@ msgstr "" msgid "Live events appear occasionally when something exciting is happening. If you'd like, you can hide this particular event, or all events for this placement in your app interface." msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:244 +#: src/features/liveNow/components/LiveStatusDialog.tsx:245 msgid "Live feature is in beta" msgstr "" @@ -6904,18 +6904,18 @@ msgstr "Message {displayName}" msgid "Message deleted" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:100 +#: src/components/dms/MessageOverlays.tsx:111 msgctxt "toast" msgid "Message deleted" msgstr "" -#: src/components/dms/MessageItem.tsx:553 +#: src/components/dms/MessageItem.tsx:522 msgid "Message failed to send." msgstr "Message failed to send." #. placeholder {0}: sender?.handle ?? 'unknown' #. placeholder {1}: message.text -#: src/components/dms/MessageContextMenu.tsx:146 +#: src/components/dms/MessageContextMenu.tsx:129 msgid "Message from @{0}: {1}" msgstr "" @@ -6938,7 +6938,7 @@ msgstr "" 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:145 +#: src/components/dms/MessageContextMenu.tsx:128 msgid "Message options" msgstr "" @@ -6946,11 +6946,11 @@ msgstr "" msgid "Messages" msgstr "" -#: src/components/dms/MessageItem.tsx:652 +#: src/components/dms/MessageItem.tsx:621 msgid "Messages from this person are hidden while they are blocking you." msgstr "Messages from this person are hidden while they are blocking you." -#: src/components/dms/MessageItem.tsx:647 +#: src/components/dms/MessageItem.tsx:616 msgid "Messages from this person are hidden while you are blocking them." msgstr "Messages from this person are hidden while you are blocking them." @@ -7229,8 +7229,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:340 -#: src/components/moderation/ReportDialog/index.tsx:356 +#: src/components/moderation/ReportDialog/index.tsx:342 +#: src/components/moderation/ReportDialog/index.tsx:358 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -7452,7 +7452,7 @@ msgstr "No GIFs found for \"{query}\"." msgid "No GIFs to show right now. Try again in a moment." msgstr "No GIFs to show right now. Try again in a moment." -#: src/features/liveNow/components/LinkPreview.tsx:63 +#: src/features/liveNow/components/LinkPreview.tsx:64 msgid "No image" msgstr "" @@ -7640,7 +7640,7 @@ msgstr "Not followed by anyone you’re following" msgid "Not Found" msgstr "" -#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:130 +#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:131 msgid "Not ready to hit post? Keep your best ideas in Drafts until the timing is just right." msgstr "" @@ -7724,7 +7724,7 @@ msgstr "" #: src/components/BotAccountAlert.tsx:52 #: src/components/BotAccountAlert.tsx:57 #: src/components/dms/InitiateChatFlow.tsx:677 -#: src/components/dms/MessageItem.tsx:659 +#: src/components/dms/MessageItem.tsx:628 #: src/screens/Login/PasswordUpdatedForm.tsx:37 #: src/screens/PostThread/components/ThreadItemAnchor.tsx:661 msgid "Okay" @@ -7916,8 +7916,8 @@ msgstr "" msgid "Open post options menu" msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:218 -#: src/features/liveNow/components/LiveStatusDialog.tsx:228 +#: src/features/liveNow/components/LiveStatusDialog.tsx:219 +#: src/features/liveNow/components/LiveStatusDialog.tsx:229 msgid "Open profile" msgstr "" @@ -8005,7 +8005,7 @@ msgstr "" msgid "Opens flow to sign in to your existing Bluesky account" msgstr "" -#: src/components/images/Gallery/index.tsx:437 +#: src/components/images/Gallery/index.tsx:444 msgid "Opens full image" msgstr "Opens full image" @@ -8022,7 +8022,7 @@ msgstr "" msgid "Opens link {0}" msgstr "" -#: src/view/com/util/UserAvatar.tsx:599 +#: src/view/com/util/UserAvatar.tsx:600 msgid "Opens live status dialog" msgstr "" @@ -8053,7 +8053,7 @@ msgstr "" #: src/components/dms/MessageItem.tsx:229 #: src/view/com/notifications/NotificationFeedItem.tsx:1019 -#: src/view/com/util/UserAvatar.tsx:617 +#: src/view/com/util/UserAvatar.tsx:618 msgid "Opens this profile" msgstr "" @@ -8129,8 +8129,8 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:86 #: src/components/dms/AfterReportConversationDialog.tsx:213 -#: src/components/dms/AfterReportDialog.tsx:84 -#: src/components/dms/AfterReportDialog.tsx:176 +#: src/components/dms/AfterReportDialog.tsx:89 +#: src/components/dms/AfterReportDialog.tsx:181 msgid "Our moderation team has received your report." msgstr "" @@ -8661,7 +8661,7 @@ msgstr "" msgid "Press to view followers of this account that you also follow" msgstr "" -#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:120 +#: src/features/liveEvents/components/LiveEventFeedCardWide.tsx:121 msgid "Preview" msgstr "" @@ -8865,8 +8865,8 @@ msgstr "Re-enable link" msgid "React with {emoji}" msgstr "" -#: src/components/dms/ReactionsDialog.tsx:65 -#: src/components/dms/ReactionsDialog.tsx:90 +#: src/components/dms/ReactionsDialog.tsx:66 +#: src/components/dms/ReactionsDialog.tsx:94 msgid "Reactions" msgstr "Reactions" @@ -8884,8 +8884,8 @@ msgctxt "english-only-resource" msgid "read about how to use search filters" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:160 -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:171 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:162 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:173 msgid "Read blog post" msgstr "" @@ -9020,13 +9020,13 @@ msgstr "" msgid "Remove attachment" msgstr "" -#: src/view/com/util/UserAvatar.tsx:519 -#: src/view/com/util/UserAvatar.tsx:522 +#: src/view/com/util/UserAvatar.tsx:520 +#: src/view/com/util/UserAvatar.tsx:523 msgid "Remove Avatar" msgstr "" -#: src/view/com/util/UserBanner.tsx:189 -#: src/view/com/util/UserBanner.tsx:192 +#: src/view/com/util/UserBanner.tsx:190 +#: src/view/com/util/UserBanner.tsx:193 msgid "Remove Banner" msgstr "" @@ -9261,8 +9261,8 @@ msgstr "" msgid "Reply was successfully hidden" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:193 -#: src/features/liveNow/components/LiveStatusDialog.tsx:266 +#: src/components/dms/MessageContextMenu.tsx:176 +#: src/features/liveNow/components/LiveStatusDialog.tsx:267 #: src/screens/Messages/ConversationSettings/index.tsx:517 msgid "Report" msgstr "" @@ -9279,8 +9279,8 @@ msgstr "" msgid "Report conversation" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:96 -#: src/components/moderation/ReportDialog/index.tsx:261 +#: src/components/moderation/ReportDialog/index.tsx:98 +#: src/components/moderation/ReportDialog/index.tsx:263 msgid "Report dialog" msgstr "" @@ -9294,7 +9294,7 @@ msgstr "" msgid "Report list" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:190 +#: src/components/dms/MessageContextMenu.tsx:173 msgid "Report message" msgstr "" @@ -9314,8 +9314,8 @@ msgstr "" #: src/components/dms/AfterReportConversationDialog.tsx:83 #: src/components/dms/AfterReportConversationDialog.tsx:210 -#: src/components/dms/AfterReportDialog.tsx:81 -#: src/components/dms/AfterReportDialog.tsx:173 +#: src/components/dms/AfterReportDialog.tsx:86 +#: src/components/dms/AfterReportDialog.tsx:178 msgid "Report submitted" msgstr "" @@ -9337,7 +9337,7 @@ msgid "Report this list" msgstr "" #: src/components/moderation/ReportDialog/copy.ts:19 -#: src/features/liveNow/components/LiveStatusDialog.tsx:249 +#: src/features/liveNow/components/LiveStatusDialog.tsx:250 msgid "Report this livestream" msgstr "" @@ -9530,7 +9530,7 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:355 #: src/components/Error.tsx:65 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:295 +#: src/components/moderation/ReportDialog/index.tsx:297 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:377 @@ -9553,7 +9553,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:292 +#: src/components/moderation/ReportDialog/index.tsx:294 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -9638,7 +9638,7 @@ msgid "Save draft?" msgstr "" #: src/components/Lightbox/chrome/ImageMenu.tsx:99 -#: src/components/MediaPreview.tsx:196 +#: src/components/MediaPreview.tsx:197 #: src/components/Post/Embed/ImageContextMenu.tsx:70 #: src/components/StarterPack/ShareDialog.tsx:144 #: src/components/StarterPack/ShareDialog.tsx:150 @@ -9674,7 +9674,7 @@ msgstr "" msgid "Saved Feeds" msgstr "" -#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:144 +#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:145 #: src/Navigation.tsx:555 #: src/screens/Bookmarks/index.tsx:59 msgid "Saved Posts" @@ -9899,7 +9899,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:378 +#: src/components/moderation/ReportDialog/index.tsx:380 msgid "Select a reason" msgstr "" @@ -9990,7 +9990,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:428 +#: src/components/moderation/ReportDialog/index.tsx:430 msgid "Select moderation service" msgstr "" @@ -10092,7 +10092,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:816 +#: src/components/moderation/ReportDialog/index.tsx:818 msgid "Send report to {title}" msgstr "" @@ -10114,7 +10114,7 @@ msgid "Send via direct message" msgstr "" #. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', }) -#: src/components/dms/MessageContextMenu.tsx:154 +#: src/components/dms/MessageContextMenu.tsx:137 msgid "Sent at {0}" msgstr "Sent at {0}" @@ -10219,7 +10219,7 @@ msgstr "" msgid "Sexually Suggestive" msgstr "" -#: src/components/MediaPreview.tsx:202 +#: src/components/MediaPreview.tsx:203 #: src/components/Post/Embed/ImageContextMenu.tsx:74 #: src/components/StarterPack/QrCodeDialog.tsx:195 #: src/screens/Hashtag.tsx:130 @@ -10309,16 +10309,16 @@ msgstr "" #: src/components/moderation/ScreenHider.tsx:179 #: src/components/moderation/ScreenHider.tsx:182 -#: src/features/liveNow/components/LiveStatusDialog.tsx:317 -#: src/features/liveNow/components/LiveStatusDialog.tsx:321 +#: src/features/liveNow/components/LiveStatusDialog.tsx:318 +#: src/features/liveNow/components/LiveStatusDialog.tsx:322 #: src/screens/List/ListHiddenScreen.tsx:194 #: src/screens/VideoFeed/index.tsx:646 #: src/screens/VideoFeed/index.tsx:652 msgid "Show anyway" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:181 -#: src/screens/Settings/AutomationLabelSettings.tsx:194 +#: src/screens/Settings/AutomationLabelSettings.tsx:185 +#: src/screens/Settings/AutomationLabelSettings.tsx:198 msgid "Show automation label" msgstr "Show automation label" @@ -10412,7 +10412,7 @@ msgid "Show warning and filter from feeds" msgstr "" #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:60 -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:170 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:171 msgid "Show when you’re live" msgstr "" @@ -10557,7 +10557,7 @@ msgstr "" msgid "Skip to next step" msgstr "" -#: src/components/images/Gallery/index.tsx:420 +#: src/components/images/Gallery/index.tsx:427 msgid "slide" msgstr "slide" @@ -10614,7 +10614,7 @@ msgid "Someone left the group" msgstr "Someone left the group" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:294 +#: src/components/dms/MessageItem.tsx:270 msgid "Someone reacted {0}" msgstr "" @@ -10639,7 +10639,7 @@ msgstr "Someone was removed" msgid "Someone was removed from the group" msgstr "Someone was removed from the group" -#: src/components/moderation/ReportDialog/index.tsx:101 +#: src/components/moderation/ReportDialog/index.tsx:103 msgid "Something wasn't quite right with the data you're trying to report. Please contact support." msgstr "" @@ -10650,7 +10650,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:287 +#: src/components/moderation/ReportDialog/index.tsx:289 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -10670,7 +10670,7 @@ msgstr "" msgid "Something went wrong. Please try again in a moment." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:245 +#: src/components/moderation/ReportDialog/index.tsx:247 msgid "Something went wrong. Please try again." msgstr "" @@ -10812,7 +10812,7 @@ msgstr "" msgid "Storybook" msgstr "" -#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:181 +#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:182 msgid "Streaming on Twitch? Set your live status on Bluesky to add a badge to your avatar. Tapping it takes people straight to your stream." msgstr "" @@ -10837,9 +10837,9 @@ msgstr "" msgid "Submit Appeal" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:506 -#: src/components/moderation/ReportDialog/index.tsx:567 -#: src/components/moderation/ReportDialog/index.tsx:574 +#: src/components/moderation/ReportDialog/index.tsx:508 +#: src/components/moderation/ReportDialog/index.tsx:569 +#: src/components/moderation/ReportDialog/index.tsx:576 msgid "Submit report" msgstr "" @@ -10848,13 +10848,13 @@ msgid "Subscribe" msgstr "" #. placeholder {0}: highlightedPublisher.name -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:425 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:434 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:426 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:435 msgid "Subscribe on {0}" msgstr "Subscribe on {0}" #. placeholder {0}: highlightedPublisher.name -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:433 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:434 msgid "Subscribe to {publicationTitle} on {0}" msgstr "Subscribe to {publicationTitle} on {0}" @@ -10985,7 +10985,7 @@ msgstr "" msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region." msgstr "" -#: src/components/dms/MessageItem.tsx:598 +#: src/components/dms/MessageItem.tsx:567 msgid "Tap for details" msgstr "Tap for details" @@ -11015,29 +11015,29 @@ msgstr "" msgid "Tap to dismiss" msgstr "" -#: src/components/dms/ReactionsDialog.tsx:192 +#: src/components/dms/ReactionsDialog.tsx:196 msgid "Tap to remove" msgstr "Tap to remove" #. placeholder {0}: reaction.value -#: src/components/dms/ReactionsDialog.tsx:208 +#: src/components/dms/ReactionsDialog.tsx:212 msgid "Tap to remove your {0} reaction" msgstr "Tap to remove your {0} reaction" -#: src/components/dms/MessageItem.tsx:563 +#: src/components/dms/MessageItem.tsx:532 msgid "Tap to retry" msgstr "Tap to retry" #. placeholder {0}: tab.value -#: src/components/dms/ReactionsDialog.tsx:354 +#: src/components/dms/ReactionsDialog.tsx:358 msgid "Tap to show {0} reactions" msgstr "Tap to show {0} reactions" -#: src/components/dms/ReactionsDialog.tsx:353 +#: src/components/dms/ReactionsDialog.tsx:357 msgid "Tap to show all reactions" msgstr "Tap to show all reactions" -#: src/components/dms/MessageItem.tsx:317 +#: src/components/dms/MessageItem.tsx:293 msgid "Tap to view reactions" msgstr "Tap to view reactions" @@ -11427,7 +11427,7 @@ msgstr "" msgid "This author has chosen to make their posts visible only to people who are signed in." msgstr "" -#: src/screens/Profile/components/GermButton.tsx:243 +#: src/screens/Profile/components/GermButton.tsx:244 msgid "This button lets others open the Germ DM app to send you a message. You can manage its visibility from the Germ DM app, or you can disconnect your Bluesky account from Germ DM altogether by clicking the button below." msgstr "" @@ -11561,7 +11561,7 @@ msgstr "" msgid "This is not a valid link" msgstr "" -#: src/screens/Settings/AutomationLabelSettings.tsx:169 +#: src/screens/Settings/AutomationLabelSettings.tsx:173 msgid "This label lets the world know that this account is automated. If turned on, this label appears next to the account's name on their profile and posts. It can be turned on or off at any time." msgstr "This label lets the world know that this account is automated. If turned on, this label appears next to the account's name on their profile and posts. It can be turned on or off at any time." @@ -11594,13 +11594,13 @@ msgstr "" msgid "This list is empty." msgstr "" -#: src/components/dms/MessageItem.tsx:596 -#: src/components/dms/MessageItem.tsx:625 +#: src/components/dms/MessageItem.tsx:565 +#: src/components/dms/MessageItem.tsx:594 msgid "This message is hidden because this user is blocking you." msgstr "This message is hidden because this user is blocking you." -#: src/components/dms/MessageItem.tsx:595 -#: src/components/dms/MessageItem.tsx:621 +#: src/components/dms/MessageItem.tsx:564 +#: src/components/dms/MessageItem.tsx:590 msgid "This message is hidden because you are blocking this user." msgstr "This message is hidden because you are blocking this user." @@ -11835,8 +11835,8 @@ msgstr "" msgid "Topic" msgstr "" -#: src/components/dms/MessageContextMenu.tsx:162 -#: src/components/dms/MessageContextMenu.tsx:165 +#: src/components/dms/MessageContextMenu.tsx:145 +#: src/components/dms/MessageContextMenu.tsx:148 #: src/components/Post/Translated/index.tsx:150 #: src/components/Post/Translated/index.tsx:157 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:553 @@ -11887,7 +11887,7 @@ msgstr "" msgid "Trolling" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:140 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:142 msgid "Trust emerges from relationships, communities, and shared context, so we’re also enabling <0>trusted verifiers: organizations that can directly issue verification." msgstr "" @@ -11980,7 +11980,7 @@ msgstr "" msgid "Unavailable feed information" msgstr "" -#: src/components/dms/MessageItem.tsx:663 +#: src/components/dms/MessageItem.tsx:632 #: src/components/dms/MessagesListBlockedFooter.tsx:97 #: src/components/dms/MessagesListBlockedFooter.tsx:104 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:222 @@ -12026,8 +12026,8 @@ msgstr "" #: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:79 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:40 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:46 -#: src/screens/Profile/components/GermButton.tsx:185 #: src/screens/Profile/components/GermButton.tsx:186 +#: src/screens/Profile/components/GermButton.tsx:187 msgid "Undo" msgstr "" @@ -12061,7 +12061,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:490 +#: src/components/moderation/ReportDialog/index.tsx:492 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -12287,22 +12287,22 @@ msgstr "" msgid "Upload a text file to:" msgstr "" -#: src/view/com/util/UserAvatar.tsx:490 -#: src/view/com/util/UserAvatar.tsx:493 -#: src/view/com/util/UserBanner.tsx:160 -#: src/view/com/util/UserBanner.tsx:163 +#: src/view/com/util/UserAvatar.tsx:491 +#: src/view/com/util/UserAvatar.tsx:494 +#: src/view/com/util/UserBanner.tsx:161 +#: src/view/com/util/UserBanner.tsx:164 msgid "Upload from Camera" msgstr "" -#: src/view/com/util/UserAvatar.tsx:507 -#: src/view/com/util/UserBanner.tsx:177 +#: src/view/com/util/UserAvatar.tsx:508 +#: src/view/com/util/UserBanner.tsx:178 msgid "Upload from Files" msgstr "" -#: src/view/com/util/UserAvatar.tsx:501 -#: src/view/com/util/UserAvatar.tsx:505 -#: src/view/com/util/UserBanner.tsx:171 -#: src/view/com/util/UserBanner.tsx:175 +#: src/view/com/util/UserAvatar.tsx:502 +#: src/view/com/util/UserAvatar.tsx:506 +#: src/view/com/util/UserBanner.tsx:172 +#: src/view/com/util/UserBanner.tsx:176 msgid "Upload from Library" msgstr "" @@ -12363,7 +12363,7 @@ msgid "user" msgstr "user" #: src/components/dms/AfterReportConversationDialog.tsx:187 -#: src/components/dms/AfterReportDialog.tsx:150 +#: src/components/dms/AfterReportDialog.tsx:155 msgctxt "toast" msgid "User blocked" msgstr "" @@ -12631,8 +12631,8 @@ msgid "View" msgstr "" #. placeholder {0}: view.source.title -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:319 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:588 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:320 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:589 msgid "View {0}" msgstr "View {0}" @@ -12661,7 +12661,7 @@ msgstr "View {0}’s profile" msgid "View {displayName}’s profile" msgstr "View {displayName}’s profile" -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:436 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:437 msgid "View {publicationTitle}" msgstr "View {publicationTitle}" @@ -12728,10 +12728,10 @@ msgstr "" msgid "View profile banner" msgstr "View profile banner" -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:319 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:426 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:437 -#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:588 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:320 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:427 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:438 +#: src/components/Post/Embed/StandardSiteEmbed/index.tsx:589 msgid "View publication" msgstr "View publication" @@ -12785,8 +12785,8 @@ msgstr "" msgid "View your verifications" msgstr "" -#: src/components/images/AutoSizedImage.tsx:220 -#: src/components/images/AutoSizedImage.tsx:252 +#: src/components/images/AutoSizedImage.tsx:221 +#: src/components/images/AutoSizedImage.tsx:253 msgid "Views full image" msgstr "" @@ -12829,8 +12829,8 @@ msgstr "" msgid "Warn content and filter from feeds" msgstr "" -#: src/features/liveNow/components/LiveStatusDialog.tsx:189 -#: src/features/liveNow/components/LiveStatusDialog.tsx:198 +#: src/features/liveNow/components/LiveStatusDialog.tsx:190 +#: src/features/liveNow/components/LiveStatusDialog.tsx:199 msgid "Watch now" msgstr "" @@ -12977,7 +12977,7 @@ msgstr "" msgid "We’re having network issues, try again" msgstr "We’re having network issues, try again" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:96 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:97 msgid "We’re introducing a new layer of verification on Bluesky — an easy-to-see checkmark." msgstr "" @@ -13063,7 +13063,7 @@ msgstr "" msgid "What's up?" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:148 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:150 msgid "When you tap on a check, you’ll see which organizations have granted verification." msgstr "" @@ -13080,7 +13080,7 @@ msgstr "Who can join this group chat and how" msgid "Who can reply" msgstr "" -#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:129 +#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:131 msgid "Who can verify?" msgstr "" @@ -13132,7 +13132,7 @@ msgstr "" msgid "Why should this user be reviewed?" msgstr "" -#: src/components/dms/AfterReportDialog.tsx:46 +#: src/components/dms/AfterReportDialog.tsx:51 msgid "Would you like to block this user and/or delete this conversation?" msgstr "" @@ -13224,7 +13224,7 @@ msgid "You are accessing Bluesky from a region that legally requires us to verif msgstr "" #. placeholder {0}: sanitizeHandle(profile.handle, '@') -#: src/components/dms/MessageItem.tsx:636 +#: src/components/dms/MessageItem.tsx:605 msgid "You are blocking {0}" msgstr "You are blocking {0}" @@ -13316,7 +13316,7 @@ msgstr "" msgid "You can continue ongoing conversations regardless of which setting you choose." msgstr "" -#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:149 +#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:150 msgid "You can now choose to be notified when specific people post. If there’s someone you want timely updates from, go to their profile and find the new bell icon near the follow button." msgstr "" @@ -13552,7 +13552,7 @@ msgid "You probably want to restart the app now." msgstr "" #. placeholder {0}: reaction.value -#: src/components/dms/MessageItem.tsx:287 +#: src/components/dms/MessageItem.tsx:263 msgid "You reacted {0}" msgstr "" @@ -13841,7 +13841,7 @@ msgstr "" msgid "Your preferred language" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:100 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:102 #: src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx:53 msgid "Your profile picture" msgstr "" @@ -13859,7 +13859,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:517 +#: src/components/moderation/ReportDialog/index.tsx:519 msgid "Your report will be sent to <0>{0}." msgstr "" diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 6233c467ff..b269046534 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type LayoutChangeEvent, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {moderateProfile} from '@atproto/api' +import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import { ScrollEdgeEffect, ScrollEdgeEffectProvider, @@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useConvoQuery} from '#/state/queries/messages/conversation' +import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read' import {useSession} from '#/state/session' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, web} from '#/alf' @@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env' import {ChatDisabled} from './components/ChatDisabled' import {ChatEnded} from './components/ChatEnded' import {ChatLocked} from './components/ChatLocked' +import {RequestStatus} from './components/RequestStatus' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -180,6 +182,12 @@ function InnerReady({ const {needsEmailVerification} = useEmail() const emailDialogControl = useEmailDialogControl() + const unreadRequestCount = + convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind) + ? (convo.view.kind.unreadJoinRequestCount ?? 0) + : 0 + const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id) + /** * Must be non-reactive, otherwise the update to open the global dialog will * cause a re-render loop. @@ -264,8 +272,25 @@ function InnerReady({ {header} ) : ( - header + {header} )} + + {isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? ( + { + markJoinRequestsRead() + }} + onPress={() => { + markJoinRequestsRead() + navigation.navigate('MessagesJoinRequests', { + conversation: convo.view.id, + }) + }} + /> + ) : null} + {isActive && ( @@ -420,15 +419,15 @@ function Header({ {count === undefined ? ( Requests to join ) : hasMoreRequests ? ( - l({ - message: `${count}+ requests to join`, - comment: - 'Displayed when there are more requests to join a group chat than have been loaded', - }) + ) : ( diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index eb002c581d..e8c0b9cc68 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -25,6 +25,7 @@ import { precacheConvoQuery, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' +import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' @@ -214,15 +215,15 @@ function GroupChatItem({ primaryProfileModeration={moderation} isBlockedAccount={false} isDeletedAccount={false} - subtitle={ - convo.details.joinRequestCount - ? convo.details.joinRequestCount > 20 + requestInfo={ + convo.details.unreadJoinRequestCount + ? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD ? l({ - message: '20+ new join requests', + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, context: 'Displayed when there are more than 20 requests to join a group chat', }) - : plural(convo.details.joinRequestCount, { + : plural(convo.details.unreadJoinRequestCount, { one: '# new join request', other: '# new join requests', }) @@ -241,6 +242,7 @@ function BaseChatItem({ avatar, title, subtitle, + requestInfo, accessibilityHint, isDeletedAccount, isBlockedAccount, @@ -256,6 +258,7 @@ function BaseChatItem({ avatar: React.ReactNode title: string subtitle?: string + requestInfo?: string accessibilityHint: string isDeletedAccount: boolean isBlockedAccount: boolean @@ -280,8 +283,10 @@ function BaseChatItem({ const playHaptic = useHaptics() const queryClient = useQueryClient() const hasUnread = - convo.view.unreadCount > 0 && !isDeletedAccount && + (convo.view.unreadCount > 0 || + (convo.kind === 'group' && + (convo.details.unreadJoinRequestCount ?? 0) > 0)) && (convo.kind !== 'group' || convo.details.lockStatus === 'unlocked') const blockInfo = useMemo(() => { @@ -607,6 +612,19 @@ function BaseChatItem({ {postAlerts} + {requestInfo && ( + + {requestInfo} + + )} + {LastMessageIcon && ( diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx index ad6d4a08e5..6e898a0a9c 100644 --- a/src/screens/Messages/components/InviteLinkDialog.tsx +++ b/src/screens/Messages/components/InviteLinkDialog.tsx @@ -5,8 +5,7 @@ import { moderateProfile, type ModerationOpts, } from '@atproto/api' -import {plural} from '@lingui/core/macro' -import {Trans, useLingui} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' @@ -178,11 +177,7 @@ export function InviteLinkDialog({ Group chats can only have a maximum of{' '} - {plural(convo.details.memberLimit, { - one: '# person', - other: '# people', - })} - . + . diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 0aa132e232..c6899fb9d0 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {isBskyPostUrl} from '#/lib/strings/url-helpers' +import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers' import {useEmail} from '#/state/email-verification' import { useMessageDraft, @@ -233,7 +233,11 @@ export function MessageComposer({ }} onChange={handleChange} onFacetCommitted={facet => { - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { + if ( + facet.type === 'url' && + (isBskyPostUrl(facet.value) || + isBskyChatInviteUrl(facet.value)) + ) { setEmbed(facet.value) } }} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 5bc2f38a05..a73a6564fb 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -18,6 +18,8 @@ import { } from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, + isBskyChatInviteUrl, isBskyPostUrl, makeRecordUri, } from '#/lib/strings/url-helpers' @@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post' import {PostMeta} from '#/view/com/util/PostMeta' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' +import * as ChatInvite from '#/components/dms/ChatInvite' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' import * as MediaPreview from '#/components/MediaPreview' @@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' +/** + * The embed staged in the message composer. A message can carry at most one + * embed: either a quoted post or a group chat invite link. + */ +export type MessageEmbedState = + | {type: 'post'; uri: string} + | {type: 'invite'; code: string} + export function useMessageEmbed() { const route = useRoute>() const navigation = useNavigation() const embedFromParams = route.params.embed - const [embedUri, setEmbedUri] = useState(embedFromParams) + const [embed, setEmbedState] = useState( + embedFromParams ? {type: 'post', uri: embedFromParams} : undefined, + ) - if (embedFromParams && embedUri !== embedFromParams) { - setEmbedUri(embedFromParams) + if (embedFromParams && embed?.type !== 'post') { + setEmbedState({type: 'post', uri: embedFromParams}) } return { - embedUri, + embed, setEmbed: useCallback( (embedUrl: string | undefined) => { if (!embedUrl) { + // Only the post embed is reflected in the route param (used by the + // share-to-DM intent flow); invites are local-only. navigation.setParams({embed: ''}) - setEmbedUri(undefined) + setEmbedState(undefined) return } if (embedFromParams) return - const url = convertBskyAppUrlIfNeeded(embedUrl) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + if (isBskyChatInviteUrl(embedUrl)) { + const code = getChatInviteCodeFromUrl(embedUrl) + if (code) { + setEmbedState({type: 'invite', code}) + } + return + } - setEmbedUri(uri) + if (isBskyPostUrl(embedUrl)) { + const url = convertBskyAppUrlIfNeeded(embedUrl) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + setEmbedState({type: 'post', uri}) + } }, [embedFromParams, navigation], ), @@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets( for (const facet of rt.facets ?? []) { for (const feature of facet.features) { - if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { + if ( + AppBskyRichtextFacet.isLink(feature) && + (isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri)) + ) { uriFromFacet = feature.uri break } @@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets( } export function MessageInputEmbed({ - embedUri, + embed, setEmbed, }: { - embedUri: string | undefined + embed: MessageEmbedState | undefined setEmbed: (embedUrl: string | undefined) => void +}) { + const onRemove = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setEmbed(undefined) + }, [setEmbed]) + + if (!embed) { + return null + } + + switch (embed.type) { + case 'post': + return + case 'invite': + return + } +} + +function MessageInputPostEmbed({ + uri, + onRemove, +}: { + uri: string + onRemove: () => void }) { const t = useTheme() const {t: l} = useLingui() - const {data: post, status} = usePostQuery(embedUri) + const {data: post, status} = usePostQuery(uri) const moderationOpts = useModerationOpts() const moderation = useMemo( @@ -134,15 +185,6 @@ export function MessageInputEmbed({ return {rt: undefined, record: undefined} }, [post]) - if (!embedUri) { - return null - } - - const onRemove = () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setEmbed(undefined) - } - switch (status) { case 'pending': { return ( @@ -220,6 +262,71 @@ export function MessageInputEmbed({ } } +function MessageInputInviteEmbed({ + code, + onRemove, +}: { + code: string + onRemove: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + + ) +} + +function MessageInputInviteEmbedBody() { + const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + Could not load invite + + + ) + } + + return +} + function SimpleContainer({ children, onRemove, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index fbebdc0745..6f56c6a898 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -28,6 +28,7 @@ import { type AppBskyEmbedRecord, AppBskyRichtextFacet, ChatBskyConvoDefs, + type ChatBskyEmbedJoinLink, RichText, } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' @@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, + getChatInviteCodeFromUrl, isBskyPostUrl, } from '#/lib/strings/url-helpers' import {logger} from '#/logger' @@ -46,9 +48,10 @@ import { useConvoActive, } from '#/state/messages/convo' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' +import {useGetJoinLinkPreview} from '#/state/queries/join-links' import {useGetPost} from '#/state/queries/post' import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageInput} from '#/screens/Messages/components/MessageInput' @@ -131,8 +134,10 @@ export function MessagesList({ const ax = useAnalytics() const convoState = useConvoActive() const agent = useAgent() + const {hasSession} = useSession() const getPost = useGetPost() - const {embedUri, setEmbed} = useMessageEmbed() + const getJoinLinkPreview = useGetJoinLinkPreview() + const {embed: messageEmbed, setEmbed} = useMessageEmbed() const t = useTheme() const textInputId = 'chat-input-' + useId() @@ -348,12 +353,38 @@ export function MessagesList({ // we want to remove the post link from the text, re-trim, then detect facets rt.detectFacetsWithoutResolution() - let embed: $Typed | undefined - let embedView: $Typed | undefined + let embed: + | $Typed + | $Typed + | undefined + let embedView: + | $Typed + | $Typed + | undefined - if (embedUri) { + // Find the embedded link facet and, if it's at the start or end of the + // message, remove it from the text (the embed card replaces it). + const stripLinkFacet = (predicate: (uri: string) => boolean) => { + const linkFacet = rt.facets?.find(facet => + facet.features.find( + feature => + AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri), + ), + ) + if (linkFacet) { + const isAtStart = linkFacet.index.byteStart === 0 + const isAtEnd = + linkFacet.index.byteEnd === rt.unicodeText.graphemeLength + if (isAtStart || isAtEnd) { + rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd) + } + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } + } + + if (messageEmbed?.type === 'post') { try { - const post = await getPost({uri: embedUri}) + const post = await getPost({uri: messageEmbed.uri}) if (post) { embed = { $type: 'app.bsky.embed.record', @@ -368,42 +399,34 @@ export function MessagesList({ record: createEmbedViewRecordFromPost(post), } - // look for the embed uri in the facets, so we can remove it from the text - const postLinkFacet = rt.facets?.find(facet => { - return facet.features.find(feature => { - if (AppBskyRichtextFacet.isLink(feature)) { - if (isBskyPostUrl(feature.uri)) { - const url = convertBskyAppUrlIfNeeded(feature.uri) - const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) - - // this might have a handle instead of a DID - // so just compare the rkey - not particularly dangerous - return post.uri.endsWith(rkey) - } - } - return false - }) + stripLinkFacet(uri => { + if (!isBskyPostUrl(uri)) return false + const url = convertBskyAppUrlIfNeeded(uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) }) - - if (postLinkFacet) { - const isAtStart = postLinkFacet.index.byteStart === 0 - const isAtEnd = - postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength - - // remove the post link from the text - if (isAtStart || isAtEnd) { - rt.delete( - postLinkFacet.index.byteStart, - postLinkFacet.index.byteEnd, - ) - } - - rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) - } } } catch (error) { logger.error('Failed to get post as quote for DM', {error}) } + } else if (messageEmbed?.type === 'invite') { + const code = messageEmbed.code + embed = { + $type: 'chat.bsky.embed.joinLink', + code, + } + + const joinLinkPreview = await getJoinLinkPreview({code, hasSession}) + if (joinLinkPreview) { + embedView = { + $type: 'chat.bsky.embed.joinLink#view', + joinLinkPreview, + } + } + + stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } await rt.detectFacets(agent) @@ -424,7 +447,16 @@ export function MessagesList({ embedView, ) }, - [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], + [ + agent, + convoState, + messageEmbed, + getPost, + getJoinLinkPreview, + hasSession, + hasScrolled, + setHasScrolled, + ], ) const scrollToEndOnPress = useCallback(() => { @@ -595,11 +627,11 @@ export function MessagesList({ onSendMessage={(message: string) => void onSendMessage(message) } - hasEmbed={!!embedUri} + hasEmbed={!!messageEmbed} setEmbed={setEmbed} loading={loading}> @@ -607,11 +639,11 @@ export function MessagesList({ diff --git a/src/screens/Messages/components/RequestStatus.tsx b/src/screens/Messages/components/RequestStatus.tsx new file mode 100644 index 0000000000..9b35b69445 --- /dev/null +++ b/src/screens/Messages/components/RequestStatus.tsx @@ -0,0 +1,92 @@ +import {Pressable} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests' +import {atoms as a, tokens, useTheme} from '#/alf' +import {GlassView} from '#/components/GlassView' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_LIQUID_GLASS} from '#/env' + +export function RequestStatus({ + top, + count, + onDismiss, + onPress, +}: { + top: number + count: number + onDismiss: () => void + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + + + {count > JOIN_REQUESTS_THRESHOLD + ? l({ + message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`, + comment: + 'Displayed when the number of requests is greater than 20', + }) + : plural(count, { + one: '# new join request', + other: '# new join requests', + })} + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx index 69246fe7c3..1641547bb2 100644 --- a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx +++ b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx @@ -83,6 +83,7 @@ function Page({ style={[a.w_full, a.aspect_square]} alt={alt} accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background + useAppleWebpCodec /> {page === 1 && ( {_(msg`Your )} diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 6c84ec4c86..85ae4acc68 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -19,6 +19,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' import {compressIfNeeded} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' @@ -212,7 +213,7 @@ export function StepProfile() { } } } - image = await compressIfNeeded(image, 1000000) + image = await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB) // If we are on mobile, prefetching the image will load the image into memory before we try and display it, // stopping any brief flickers. diff --git a/src/screens/Profile/components/GermButton.tsx b/src/screens/Profile/components/GermButton.tsx index 0bef3a22c3..e37e57edbf 100644 --- a/src/screens/Profile/components/GermButton.tsx +++ b/src/screens/Profile/components/GermButton.tsx @@ -105,6 +105,7 @@ function GermLogo({size}: {size: 'small' | 'large'}) { source={require('../../../../assets/images/germ_logo.webp')} accessibilityIgnoresInvertColors={false} contentFit="cover" + useAppleWebpCodec style={[ a.rounded_full, size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16}, diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx index 2e716a219f..3388515fd0 100644 --- a/src/screens/Settings/AppIconSettings/index.tsx +++ b/src/screens/Settings/AppIconSettings/index.tsx @@ -1,9 +1,9 @@ import {useState} from 'react' import {Alert, View} from 'react-native' +import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {PressableScale} from '#/lib/custom-animations/PressableScale' diff --git a/src/screens/Settings/AppIconSettings/types.ts b/src/screens/Settings/AppIconSettings/types.ts index 02c2791dc9..77eda3036d 100644 --- a/src/screens/Settings/AppIconSettings/types.ts +++ b/src/screens/Settings/AppIconSettings/types.ts @@ -1,5 +1,5 @@ import {type ImageSourcePropType} from 'react-native' -import type * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import type * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon' export type AppIconSet = { id: DynamicAppIcon.IconName diff --git a/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts index 4bc9b665a4..95cdf5467a 100644 --- a/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts +++ b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts @@ -1,5 +1,5 @@ import {useCallback, useMemo, useState} from 'react' -import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon' import {useFocusEffect} from '@react-navigation/native' import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' diff --git a/src/state/gallery.ts b/src/state/gallery.ts index e1c1541bc3..e6c5b50d36 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -201,12 +201,17 @@ export function resetImageManipulation( return img } -export async function compressImage(img: ComposerImage): Promise { +export async function compressImage( + img: ComposerImage, + {maxDimension, maxSize}: {maxDimension: number; maxSize: number}, +): Promise { const source = img.transformed || img.source let attempts = 0 - let maxDimension = 4000 - let maxBytes = 2000000 + // Seeded from `maxDimension` but shrunk per attempt below, so keep the + // passed-in value pristine. + let currentDimension = maxDimension + const maxBytes = maxSize let minQualityPercentage = 0 let maxQualityPercentage = 101 // exclusive @@ -215,7 +220,11 @@ export async function compressImage(img: ComposerImage): Promise { while (maxQualityPercentage - minQualityPercentage > 1) { if (attempts >= 4) break - const [w, h] = containImageRes(source.width, source.height, maxDimension) + const [w, h] = containImageRes( + source.width, + source.height, + currentDimension, + ) const qualityPercentage = Math.round( (maxQualityPercentage + minQualityPercentage) / 2, ) @@ -230,8 +239,9 @@ export async function compressImage(img: ComposerImage): Promise { minQualityPercentage = 0 maxQualityPercentage = 101 attempts++ - // 4000px → 3200px → 2560px → 2048px → ~1638px - maxDimension = Math.floor(maxDimension * 0.8) + // max.width → 0.8× → 0.64× → 0.512× → ~0.41× + // e.g. 4000px → 3200px → 2560px → 2048px → ~1638px + currentDimension = Math.floor(currentDimension * 0.8) continue } diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 08b609f71a..e89965eb45 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -6,6 +6,7 @@ import { ChatBskyConvoDefs, type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, type ChatBskyGroupDefs, } from '@atproto/api' import {XRPCError} from '@atproto/api' @@ -109,7 +110,9 @@ export class Convo { { id: string message: ChatBskyConvoSendMessage.InputSchema['message'] - optimisticEmbedView?: $Typed + optimisticEmbedView?: + | $Typed + | $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -942,7 +945,9 @@ export class Convo { sendMessage( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView?: $Typed, + optimisticEmbedView?: + | $Typed + | $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 51d111356a..269f32c515 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -5,6 +5,7 @@ import { type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoSendMessage, + type ChatBskyEmbedJoinLink, } from '@atproto/api' import {type MessagesEventBus} from '#/state/messages/events/agent' @@ -108,7 +109,10 @@ export type ConvoItem = type DeleteMessage = (messageId: string) => Promise type SendMessage = ( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView: $Typed | undefined, + optimisticEmbedView: + | $Typed + | $Typed + | undefined, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 6aa10331ef..628f5e9102 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -1,4 +1,9 @@ -import {AtpAgent} from '@atproto/api' +import {useCallback} from 'react' +import { + AtpAgent, + type ChatBskyGroupDefs, + type ChatBskyGroupGetJoinLinkPreviews, +} from '@atproto/api' import {useQuery, useQueryClient} from '@tanstack/react-query' import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants' @@ -17,12 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: { persistedVersion: 1, }) -export function useJoinLinkPreviewsQuery({ +async function fetchJoinLinkPreviews({ + agent, codes, hasSession, +}: { + agent: AtpAgent + codes: string[] + hasSession: boolean +}) { + const previewAgent = new AtpAgent({service: CHAT_SERVICE}) + const res = hasSession + ? await agent.chat.bsky.group.getJoinLinkPreviews( + {codes}, + {headers: DM_SERVICE_HEADERS}, + ) + : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) + return res.data +} + +export function useJoinLinkPreviewsQuery({ + codes, + hasSession, + staleTime = STALE.MINUTES.ONE, + initialData, }: { codes?: string[] hasSession: boolean + staleTime?: number + /** + * Seed the query with an already-known preview (e.g. a DM message embed + * already carries the resolved view), avoiding a duplicate fetch. + */ + initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema }) { const agent = useAgent() @@ -31,21 +63,15 @@ export function useJoinLinkPreviewsQuery({ queryFn: async () => { if (!codes) throw new Error('No invite code') try { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data + return await fetchJoinLinkPreviews({agent, codes, hasSession}) } catch (error) { logger.error('Failed to fetch join link preview', {safeMessage: error}) throw error } }, enabled: codes != null && codes.length > 0, - staleTime: STALE.SECONDS.FIFTEEN, + staleTime, + initialData, }) } @@ -56,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() { return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => { return queryClient.prefetchQuery({ queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), - queryFn: async () => { - const previewAgent = new AtpAgent({service: CHAT_SERVICE}) - const res = hasSession - ? await agent.chat.bsky.group.getJoinLinkPreviews( - {codes}, - {headers: DM_SERVICE_HEADERS}, - ) - : await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes}) - return res.data - }, + queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}), staleTime: STALE.SECONDS.FIFTEEN, }) } } + +/** + * Imperatively fetch (or read from cache) a single join link preview by code. + * Used when sending a DM invite embed so we can build an optimistic view. + * Returns undefined if the preview can't be resolved. + */ +export function useGetJoinLinkPreview() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useCallback( + async ({ + code, + hasSession, + }: { + code: string + hasSession: boolean + }): Promise => { + try { + const data = await queryClient.fetchQuery({ + queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), + queryFn: () => + fetchJoinLinkPreviews({agent, codes: [code], hasSession}), + staleTime: STALE.SECONDS.FIFTEEN, + }) + return data.joinLinkPreviews[0] + } catch (error) { + logger.error('Failed to fetch join link preview', {safeMessage: error}) + return undefined + } + }, + [agent, queryClient], + ) +} diff --git a/src/state/queries/messages/join-requests.ts b/src/state/queries/messages/join-requests.ts index a3eda671e8..179dfafaaf 100644 --- a/src/state/queries/messages/join-requests.ts +++ b/src/state/queries/messages/join-requests.ts @@ -39,14 +39,16 @@ export function useJoinRequestMutation( return useMutation({ mutationFn: async ({member}: {member: string}) => { if (!convoId) throw new Error('No convoId provided') - const endpoint = + const {data} = action === 'approve' - ? agent.chat.bsky.group.approveJoinRequest - : agent.chat.bsky.group.rejectJoinRequest - const {data} = await endpoint( - {convoId, member}, - {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, - ) + ? await agent.chat.bsky.group.approveJoinRequest( + {convoId, member}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + : await agent.chat.bsky.group.rejectJoinRequest( + {convoId, member}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) return data as JoinRequestOutput }, onMutate: ({member}) => { diff --git a/src/state/queries/messages/list-join-requests.ts b/src/state/queries/messages/list-join-requests.ts index 9f0860c20d..64a999c118 100644 --- a/src/state/queries/messages/list-join-requests.ts +++ b/src/state/queries/messages/list-join-requests.ts @@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {STALE} from '..' +export const JOIN_REQUESTS_THRESHOLD = 20 + const listJoinRequestsQueryKeyRoot = 'list-join-requests' export const createListJoinRequestsQueryKey = (args: {convoId: string}) => @@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({ queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}), queryFn: async ({pageParam}) => { const {data} = await agent.chat.bsky.group.listJoinRequests( - {convoId: convoId!, cursor: pageParam, limit: 20}, + {convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD}, {headers: DM_SERVICE_HEADERS}, ) return data diff --git a/src/state/queries/messages/mark-join-request-read.ts b/src/state/queries/messages/mark-join-request-read.ts new file mode 100644 index 0000000000..ae6d7e88a7 --- /dev/null +++ b/src/state/queries/messages/mark-join-request-read.ts @@ -0,0 +1,85 @@ +import {ChatBskyConvoDefs} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {RQKEY as CONVO_KEY} from './conversation' +import { + type ConvoListQueryData, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useMarkJoinRequestsRead(convoId: string | undefined) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + if (!convoId) throw new Error('No convoId provided') + await agent.chat.bsky.group.updateJoinRequestsRead( + {convoId}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + }, + onMutate: () => { + if (!convoId) return + + const prevConvo = queryClient.getQueryData( + CONVO_KEY(convoId), + ) + queryClient.setQueryData( + CONVO_KEY(convoId), + old => { + if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old + return { + ...old, + kind: {...old.kind, unreadJoinRequestCount: 0}, + } + }, + ) + + const prevListEntries = queryClient.getQueriesData({ + queryKey: [CONVO_LIST_ROOT_KEY], + }) + queryClient.setQueriesData( + {queryKey: [CONVO_LIST_ROOT_KEY]}, + old => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.map(convo => { + if ( + convo.id !== convoId || + !ChatBskyConvoDefs.isGroupConvo(convo.kind) + ) { + return convo + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + } + }), + })), + } + }, + ) + + return {prevConvo, prevListEntries} + }, + onError: (error, _, context) => { + logger.error('Failed to mark join requests as read', {safeMessage: error}) + if (!convoId) return + if (context?.prevConvo) { + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) + } + for (const [key, data] of context?.prevListEntries ?? []) { + queryClient.setQueryData(key, data) + } + void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + }, + }) +} diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 8c2511b8e8..8088db88e1 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -1,6 +1,5 @@ import {BskyAgent} from '@atproto/api' -import {logger} from '#/logger' import {device} from '#/storage' export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil @@ -77,8 +76,6 @@ export function configureAdditionalModerationAuthorities() { if (geolocation?.countryCode) { // overwrite with only those necessary additionalLabelers = MODERATION_AUTHORITIES[geolocation.countryCode] ?? [] - } else { - logger.info(`no geolocation, cannot apply mod authorities`) } if (__DEV__) { @@ -89,10 +86,5 @@ export function configureAdditionalModerationAuthorities() { new Set([...BskyAgent.appLabelers, ...additionalLabelers]), ) - logger.info(`applying mod authorities`, { - additionalLabelers, - appLabelers, - }) - BskyAgent.configure({appLabelers}) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index fa1394ff27..536d83ef3f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -199,6 +199,45 @@ function applyGalleryCap( return {status: 'ok', accepted: incoming} } +function useAddImagesWithCap( + currentCount: number, + dispatchPostAction: (action: PostAction) => void, +) { + const {t: l} = useLingui() + return useCallback( + (next: ComposerImage[]) => { + const result = applyGalleryCap(currentCount, next) + if (result.status === 'full') { + Toast.show( + l({ + message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, + comment: + 'Toast shown when the user tries to add more images but the post gallery is already at the cap', + }), + {type: 'warning'}, + ) + return + } + if (result.status === 'partial') { + Toast.show( + l({ + message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, + comment: + 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', + }), + {type: 'warning'}, + ) + } + dispatchPostAction({ + type: 'embed_add_images', + images: result.accepted, + }) + }, + [currentCount, dispatchPostAction, l], + ) +} + + type Props = ComposerOpts export const ComposePost = ({ replyTo, @@ -1426,42 +1465,11 @@ let ComposerPost = memo(function ComposerPost({ [dispatch, post.id], ) - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - const media = post.embed.media - const currentCount = - media?.type === 'images' || media?.type === 'gallery' - ? media.images.length - : 0 - const result = applyGalleryCap(currentCount, next) - if (result.status === 'full') { - Toast.show( - l({ - message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, - comment: - 'Toast shown when the user tries to add more images but the post gallery is already at the cap', - }), - {type: 'warning'}, - ) - return - } - if (result.status === 'partial') { - Toast.show( - l({ - message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, - comment: - 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', - }), - {type: 'warning'}, - ) - } - dispatchPost({ - type: 'embed_add_images', - images: result.accepted, - }) - }, - [dispatchPost, l, post.embed.media], - ) + const postImagesCount = + post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery' + ? post.embed.media.images.length + : 0 + const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost) const onNewLink = useCallback( (uri: string) => { @@ -1989,37 +1997,7 @@ function ComposerFooter({ isMediaSelectionDisabled = !!media } - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - const result = applyGalleryCap(images.length, next) - if (result.status === 'full') { - Toast.show( - l({ - message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, - comment: - 'Toast shown when the user tries to add more images but the post gallery is already at the cap', - }), - {type: 'warning'}, - ) - return - } - if (result.status === 'partial') { - Toast.show( - l({ - message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, - comment: - 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', - }), - {type: 'warning'}, - ) - } - dispatch({ - type: 'embed_add_images', - images: result.accepted, - }) - }, - [dispatch, images.length, l], - ) + const onImageAdd = useAddImagesWithCap(images.length, dispatch) const onSelectGif = useCallback( (gif: Gif) => { diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 131337f955..235139ef86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -171,8 +171,8 @@ function ComposerReplyToImages({ )) || (images.length === 2 && ( @@ -180,14 +180,14 @@ function ComposerReplyToImages({ )) || @@ -196,21 +196,21 @@ function ComposerReplyToImages({ @@ -221,28 +221,28 @@ function ComposerReplyToImages({ diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index ecd22d7c39..0b14f16ed3 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' +import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' @@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({ hideAlt /> ) + } else if (data.type === 'chat-invite') { + return } else if (data.kind === 'feed') { return ( void} & ViewStyleProp) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() return (