Merge remote-tracking branch 'origin/main' into gallery-embed

This commit is contained in:
vineyardbovines
2026-06-04 17:12:43 -04:00
86 changed files with 1717 additions and 553 deletions
+1
View File
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace # superpowers plugin plans/specs — local-only workspace
docs/superpowers/ docs/superpowers/
.claude/worktrees
+23 -12
View File
@@ -1,11 +1,12 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy' import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import { import {
downloadAndResize, downloadAndResize,
type DownloadAndResizeOpts, type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip' } from '../../src/lib/media/manip'
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = { const mockResizedImage = {
path: 'file://resized-image.jpg', path: 'file://resized-image.jpg',
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
const opts: DownloadAndResizeOpts = { const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg', uri: 'https://example.com/image.jpg',
width: 100, maxDimension: 2000,
height: 100,
maxSize: 500000, maxSize: 500000,
mode: 'cover',
timeout: 10000, timeout: 10000,
} }
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
// First time it gets called is to get dimensions // First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith( expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
[{resize: {height: opts.height, width: opts.width}}], [{resize: {height: 100, width: 100}}],
{format: SaveFormat.JPEG, compress: 1.0}, {format: SaveFormat.JPEG, compress: 1.0},
) )
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
it('should return undefined for invalid URI', async () => { it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = { const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri', uri: 'invalid-uri',
width: 100, maxDimension: 2000,
height: 100,
maxSize: 500000, maxSize: 500000,
mode: 'cover',
timeout: 10000, timeout: 10000,
} }
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
width: 1200, width: 1200,
height: 1000, height: 1000,
} }
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const initialDimensionsTwo = { const initialDimensionsTwo = {
width: 1000, width: 1000,
height: 1200, height: 1200,
} }
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
expect(resizedDimensionsOne).toEqual(initialDimensionsOne) expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
width: 3000, width: 3000,
height: 1500, height: 1500,
} }
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const initialDimensionsTwo = { const initialDimensionsTwo = {
width: 2000, width: 2000,
height: 4000, height: 4000,
} }
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
expect(resizedDimensionsOne).toEqual({ expect(resizedDimensionsOne).toEqual({
width: 2000, width: 2000,
+45
View File
@@ -1,6 +1,7 @@
import {describe, expect, it} from '@jest/globals' import {describe, expect, it} from '@jest/globals'
import { import {
getChatInviteCodeFromUrl,
isPossiblyAUrl, isPossiblyAUrl,
isTrustedUrl, isTrustedUrl,
linkRequiresWarning, linkRequiresWarning,
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
expect(output).toEqual(expected) 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)
})
})
+1 -1
View File
@@ -349,7 +349,7 @@ module.exports = function (_config) {
}, },
], ],
[ [
'@mozzius/expo-dynamic-app-icon', '@bsky.app/expo-dynamic-app-icon',
{ {
/** /**
* Default set * Default set
-21
View File
@@ -43,9 +43,6 @@
} }
}, },
"src/analytics/PassiveAnalytics.tsx": { "src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"react-hooks/purity": { "react-hooks/purity": {
"count": 1 "count": 1
} }
@@ -124,11 +121,6 @@
"count": 1 "count": 1
} }
}, },
"src/components/Button.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/components/Composer/index.tsx": { "src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -261,11 +253,6 @@
"count": 3 "count": 3
} }
}, },
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": { "src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -811,14 +798,6 @@
"count": 1 "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": { "src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
+1 -1
View File
@@ -98,6 +98,7 @@
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14", "@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-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-scroll-edge-effect": "^0.1.4",
@@ -123,7 +124,6 @@
"@ipld/dag-cbor": "^9.2.7", "@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2", "@lingui/core": "^5.9.2",
"@lingui/react": "^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-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33", "@react-navigation/native": "^7.1.33",
+18 -18
View File
@@ -256,6 +256,9 @@ importers:
'@bsky.app/alf': '@bsky.app/alf':
specifier: ^0.1.14 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) 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': '@bsky.app/expo-guess-language':
specifier: ^0.2.8 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) 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': '@lingui/react':
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0) 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': '@react-native-async-storage/async-storage':
specifier: 2.2.0 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)) 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: '*'
react-native: '*' 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': '@bsky.app/expo-guess-language@0.2.8':
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==} resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
peerDependencies: peerDependencies:
@@ -2327,13 +2334,6 @@ packages:
'@messageformat/parser@5.1.1': '@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==} 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': '@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} 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-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) 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)': '@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: 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) 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: dependencies:
moo: 0.5.3 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': '@napi-rs/wasm-runtime@0.2.12':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
+13 -15
View File
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState' import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics' 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. * Tracks passive analytics like app foreground/background time.
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
}) })
} }
if (IS_DEV || IS_TESTFLIGHT) { // if (IS_DEV || IS_TESTFLIGHT) {
const feats = Object.values(Features).reduce( // const feats = Object.values(Features).reduce(
(acc, feat) => { // (acc, feat) => {
acc[feat] = features.evalFeature(feat) // acc[feat] = features.evalFeature(feat)
return acc // return acc
}, // },
{} as Record<Features, any>, // {} as Record<Features, any>,
) // )
ax.logger.info('FEATURES', { // ax.logger.info('FEATURES', {
features: feats, // features: feats,
definitions: features.getFeatures(), // definitions: features.getFeatures(),
}) // })
} // }
}) })
return () => sub.remove() return () => sub.remove()
}, [ax]) }, [ax])
-4
View File
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
} }
private async sendBatch(events: Event<M>[], isRetry: boolean = false) { private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
logger.debug(`sendBatch: ${events.length}`, {
isRetry,
})
try { try {
const body = JSON.stringify({events}) const body = JSON.stringify({events})
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
+28 -3
View File
@@ -45,7 +45,7 @@ export type ButtonColor =
| 'negative' | 'negative'
| 'primary_subtle' | 'primary_subtle'
| 'negative_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 ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
export type VariantProps = { export type VariantProps = {
/** /**
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
( (
{ {
children, children,
variant, variant: variantProp,
color, color,
size, size,
shape = 'default', shape = 'default',
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
* If a `color` is set, then we want to use the existing codepaths for * If a `color` is set, then we want to use the existing codepaths for
* "solid" buttons. This is to maintain backwards compatibility. * "solid" buttons. This is to maintain backwards compatibility.
*/ */
if (!variant && color) { let variant: VariantProps['variant'] = variantProp
if (!variantProp && color) {
variant = 'solid' variant = 'solid'
} }
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
paddingHorizontal: 24, paddingHorizontal: 24,
gap: 6, gap: 6,
}) })
} else if (size === 'medium') {
baseStyles.push(a.rounded_full, {
paddingVertical: 9,
paddingHorizontal: 28,
gap: 5,
})
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push(a.rounded_full, { baseStyles.push(a.rounded_full, {
paddingVertical: 8, paddingVertical: 8,
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
borderRadius: 10, borderRadius: 10,
gap: 3, gap: 3,
}) })
} else if (size === 'medium') {
baseStyles.push({
paddingVertical: 9,
paddingHorizontal: 16,
borderRadius: 8,
gap: 3,
})
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push({ baseStyles.push({
paddingVertical: 8, paddingVertical: 8,
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
} else { } else {
baseStyles.push({height: 44, width: 44}) 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') { } else if (size === 'small') {
if (shape === 'round') { if (shape === 'round') {
baseStyles.push({height: 33, width: 33}) baseStyles.push({height: 33, width: 33})
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
if (size === 'large') { if (size === 'large') {
baseStyles.push(a.text_md, a.font_medium) baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'medium') {
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push(a.text_sm, a.font_medium) baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') { } else if (size === 'tiny') {
@@ -799,6 +821,7 @@ export function ButtonIcon({
size ?? size ??
(({ (({
large: 'md', large: 'md',
medium: 'sm',
small: 'sm', small: 'sm',
tiny: 'xs', tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude< }[buttonSize || 'small'] || 'sm') as Exclude<
@@ -828,6 +851,7 @@ export function ButtonIcon({
*/ */
const iconContainerSize = { const iconContainerSize = {
large: 20, large: 20,
medium: 17,
small: 17, small: 17,
tiny: 15, tiny: 15,
}[buttonSize || 'small'] }[buttonSize || 'small']
@@ -841,6 +865,7 @@ export function ButtonIcon({
if (buttonShape === 'default') { if (buttonShape === 'default') {
iconNegativeMargin = { iconNegativeMargin = {
large: -2, large: -2,
medium: -2,
small: -2, small: -2,
tiny: -1, tiny: -1,
}[buttonSize || 'small'] }[buttonSize || 'small']
+1
View File
@@ -499,6 +499,7 @@ function TriggerClone({
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)} accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false} accessibilityIgnoresInvertColors={false}
cachePolicy="none"
/> />
</Animated.View> </Animated.View>
) )
+18 -6
View File
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useRef, useState} from 'react' 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 {Image} from 'expo-image'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {FocusGuards, FocusScope} from 'radix-ui/internal' import {FocusGuards, FocusScope} from 'radix-ui/internal'
@@ -226,17 +226,21 @@ function LightboxGallery({
)} )}
</View> </View>
{img.alt ? ( {img.alt ? (
<View <ScrollView
// Cap the overlay height so long alt text scrolls within the overlay
// instead of growing past the top of the screen and pushing the image
// out of view. Only scrollable once expanded.
style={[ style={[
a.px_4xl, styles.altScroll,
a.py_2xl,
{ {
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only // @ts-expect-error web only
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
}, },
delayedFadeInAnim, delayedFadeInAnim,
]}> ]}
scrollEnabled={isAltExpanded}
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
<Pressable <Pressable
accessibilityLabel={l`Expand alt text`} accessibilityLabel={l`Expand alt text`}
accessibilityHint={l`If alt text is long, toggles alt text expanded state`} accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
@@ -250,7 +254,7 @@ function LightboxGallery({
{img.alt} {img.alt}
</Text> </Text>
</Pressable> </Pressable>
</View> </ScrollView>
) : null} ) : null}
{imgs.length > 1 && ( {imgs.length > 1 && (
<div aria-live="polite" aria-atomic="true" style={a.sr_only}> <div aria-live="polite" aria-atomic="true" style={a.sr_only}>
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
boxSizing: 'border-box', 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: { menuBtn: {
top: 20, top: 20,
left: 20, left: 20,
+11 -1
View File
@@ -1,6 +1,9 @@
import {useRef} from 'react' import {useRef} from 'react'
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native' 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 {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
@@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {height: screenHeight} = useSafeAreaFrame()
const isMomentumScrolling = useRef(false) const isMomentumScrolling = useRef(false)
if (!altText) return null 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 ( return (
<View <View
style={[ style={[
@@ -46,6 +55,7 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
}), }),
]}> ]}>
<ScrollView <ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded} scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => { onMomentumScrollBegin={() => {
isMomentumScrolling.current = true isMomentumScrolling.current = true
+27 -16
View File
@@ -1,6 +1,5 @@
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {BlurView} from 'expo-blur'
import {atoms as a} from '#/alf'
type Props = { type Props = {
count: number count: number
@@ -14,26 +13,38 @@ const GAP = 5
export function PagerDots({count, activeIndex}: Props) { export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null if (count <= 1) return null
return ( return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}> <View style={styles.root}>
{Array.from({length: count}).map((_, i) => { <BlurView intensity={20} tint="dark" style={styles.inner}>
const isActive = i === activeIndex {Array.from({length: count}).map((_, i) => {
return ( const isActive = i === activeIndex
<View return (
key={i} <View
style={[ key={i}
isActive ? styles.active : styles.inactive, style={[
isActive ? styles.activeDot : styles.inactiveDot, isActive ? styles.active : styles.inactive,
]} isActive ? styles.activeDot : styles.inactiveDot,
/> ]}
) />
})} )
})}
</BlurView>
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
row: { root: {
borderRadius: 999,
overflow: 'hidden',
},
inner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP, gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}, },
activeDot: { activeDot: {
width: ACTIVE, width: ACTIVE,
@@ -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 (
<View style={styles.root}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
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)',
},
})
@@ -246,6 +246,7 @@ const ImageItem = ({
} }
} }
cachePolicy="memory" cachePolicy="memory"
useAppleWebpCodec
/> />
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
@@ -32,6 +32,7 @@ import Animated, {
withSpring, withSpring,
type WithSpringConfig, type WithSpringConfig,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Image} from 'expo-image'
import * as ScreenOrientation from 'expo-screen-orientation' import * as ScreenOrientation from 'expo-screen-orientation'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
@@ -136,6 +137,9 @@ export default function ImageViewRoot({
'worklet' 'worklet'
thumbRects.set({}) thumbRects.set({})
})() })()
requestIdleCallback(() => {
void Image.clearMemoryCache()
})
}, [thumbRects]) }, [thumbRects])
useAnimatedReaction( useAnimatedReaction(
+1
View File
@@ -161,6 +161,7 @@ export function ImageItem({
contentFit="cover" contentFit="cover"
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<MediaInsetBorder style={[a.rounded_xs]} /> <MediaInsetBorder style={[a.rounded_xs]} />
{children} {children}
@@ -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/<code>` 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<ViewStyle>
}) {
return (
<ChatInvite.Root code={code}>
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
</ChatInvite.Root>
)
}
function ChatInviteEmbedBody({
link,
onOpen,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const {error} = ChatInvite.useChatInvite()
if (error) {
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
}
return <JoinRequestEmbedBody style={[a.mt_sm, style]} onOpen={onOpen} />
}
@@ -59,7 +59,7 @@ export const ExternalEmbed = ({
const onShareExternal = useCallback(() => { const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) { if (link.uri && IS_NATIVE) {
playHaptic('Heavy') playHaptic('Heavy')
shareUrl(link.uri) void shareUrl(link.uri)
} }
}, [link.uri, playHaptic]) }, [link.uri, playHaptic])
@@ -108,6 +108,7 @@ export const ExternalEmbed = ({
source={{uri: imageUri}} source={{uri: imageUri}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
) : undefined} ) : undefined}
@@ -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<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode = code ?? preview?.code
if (!resolvedCode) return null
return (
<ChatInvite.Root code={resolvedCode} initialPreview={preview}>
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root>
)
}
/**
* 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<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
<View
style={[
a.align_center,
a.justify_center,
a.p_lg,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
}
if (!preview) {
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.gap_xs,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
}
return (
<View
style={[
a.justify_between,
a.border,
a.rounded_lg,
a.p_lg,
a.gap_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<ChatInvite.Card size="large" />
<ChatInvite.JoinButton onPress={onOpen} />
</View>
)
}
@@ -166,6 +166,7 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}} source={{uri: imageUri}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
) : undefined} ) : undefined}
@@ -355,6 +356,7 @@ export function PublicationCard({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
emoji
numberOfLines={1} numberOfLines={1}
style={[ style={[
a.text_md, a.text_md,
@@ -385,7 +387,7 @@ export function PublicationCard({
<View style={[a.pointer_events_none]}> <View style={[a.pointer_events_none]}>
{view.description && ( {view.description && (
<View style={[a.pt_sm]}> <View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}> <Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description} {view.description}
</Text> </Text>
</View> </View>
@@ -616,6 +618,7 @@ export function PublicationFooter({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
emoji
numberOfLines={1} numberOfLines={1}
style={[ style={[
a.text_sm, a.text_sm,
+17
View File
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -33,6 +34,7 @@ import {
type EmbedType, type EmbedType,
parseEmbed, parseEmbed,
} from '#/types/bsky/post' } from '#/types/bsky/post'
import {ChatInviteEmbed} from './ChatInviteEmbed'
import {ExternalEmbed} from './ExternalEmbed' import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed' import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed' import {ImageEmbed} from './ImageEmbed'
@@ -112,6 +114,21 @@ function MediaEmbed({
</ContentHider> </ContentHider>
) )
} }
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
if (chatInviteCode) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<ChatInviteEmbed
code={chatInviteCode}
link={embed.view.external}
onOpen={rest.onOpen}
style={rest.style}
/>
</ContentHider>
)
}
return ( return (
<ContentHider <ContentHider
modui={rest.moderation?.ui('contentMedia')} modui={rest.moderation?.ui('contentMedia')}
@@ -60,6 +60,7 @@ export function FindContactsBannerNUX() {
a.self_end, a.self_end,
a.mt_sm, a.mt_sm,
]} ]}
useAppleWebpCodec
/> />
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}> <View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text <Text
@@ -27,6 +27,7 @@ export function ContactsHeroImage() {
alt={_( alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
) )
@@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() {
alt={_( alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`, msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -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.', '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
/> />
</View> </View>
</View> </View>
@@ -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.', '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
/> />
</View> </View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}> <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
@@ -78,6 +78,7 @@ export function FindContactsAnnouncement() {
alt={_( alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() {
alt={_( alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
@@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() {
alt={_( 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.`, 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
/> />
</View> </View>
@@ -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.', '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
/> />
</View> </View>
</View> </View>
+90
View File
@@ -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 (
<View style={[a.flex_row, a.gap_md, a.align_center]}>
<AvatarBubbles size={56} self profiles={avatarProfiles} />
<View style={[a.flex_1, size === 'large' ? a.gap_2xs : a.gap_xs]}>
<Text
emoji
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
numberOfLines={1}>
{preview.name}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
allowFontScaling
numberOfLines={1}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[
a.text_2xs,
a.leading_tight,
a.font_medium,
t.atoms.text_contrast_high,
]}
allowFontScaling
numberOfLines={1}>
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '}
<Plural
value={preview.memberCount}
one="member"
other="members"
/>
</Trans>
</Text>
</View>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
size === 'large' && a.mt_2xs,
]}>
<Text
emoji
style={[a.flex_shrink, a.text_sm, a.font_medium]}
allowFontScaling
numberOfLines={1}>
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
By <Text style={[a.font_medium]}>{ownerDisplayName}</Text>
</Trans>
</Text>
<ProfileBadges profile={preview.owner} size="sm" />
<Text
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
allowFontScaling
numberOfLines={1}>
{ownerHandle}
</Text>
</View>
</View>
</View>
)
}
+47
View File
@@ -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<SVGIconProps>
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<ChatInviteContextValue | null>(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
@@ -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<ViewStyle>
}) {
const {action} = useChatInvite()
if (!action) return null
return (
<Button
testID="joinButton"
onPress={() => {
onPress?.()
action.onPress()
}}
label={action.label}
accessibilityHint={action.accessibilityHint}
size="medium"
color={action.color}
disabled={action.disabled}
style={[a.w_full, style]}>
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
<ButtonText>{action.label}</ButtonText>
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
</Button>
)
}
+144
View File
@@ -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<NavigationProp>()
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<SVGIconProps> = 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 (
<ChatInviteProvider
value={{code, loading, error: !!error, preview, action}}>
{children}
</ChatInviteProvider>
)
}
+8
View File
@@ -0,0 +1,8 @@
export {Card} from './Card'
export {
type ChatInviteAction,
type ChatInviteContextValue,
useChatInvite,
} from './Context'
export {JoinButton} from './JoinButton'
export {Root} from './Root'
+15 -2
View File
@@ -20,6 +20,7 @@ import {
AppBskyEmbedRecord, AppBskyEmbedRecord,
type ChatBskyActorDefs, type ChatBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
@@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
import {groupReactions} from './ReactionsDialog' import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' 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 rt = new RichTextAPI({text: message.text, facets: message.facets})
const hasEmbedAndText = const hasEmbed =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 AppBskyEmbedRecord.isView(message.embed) ||
ChatBskyEmbedJoinLink.isView(message.embed)
const hasEmbedAndText = hasEmbed && rt.text.length > 0
const targetBottomRadius = squaredBottomCorner const targetBottomRadius = squaredBottomCorner
? SQUARED_BORDER_RADIUS ? SQUARED_BORDER_RADIUS
@@ -427,6 +431,15 @@ let MessageItem = ({
squaredTopCorner={squaredTopCorner} squaredTopCorner={squaredTopCorner}
/> />
)} )}
{ChatBskyEmbedJoinLink.isView(message.embed) && (
<MessageItemInviteEmbed
embed={message.embed}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
squaredTopCorner={squaredTopCorner}
/>
)}
{rt.text.length > 0 && ( {rt.text.length > 0 && (
<Animated.View <Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`} accessibilityHint={l`Double tap or long press the message to add a reaction`}
@@ -0,0 +1,87 @@
import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, native, useTheme, web} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {MessageContextProvider} from './MessageContext'
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
let MessageItemInviteEmbed = ({
embed,
isFromSelf,
isGroupChat,
squaredTopCorner,
squaredBottomCorner,
}: {
embed: $Typed<ChatBskyEmbedJoinLink.View>
isFromSelf: boolean
isGroupChat: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
const convo = useConvoActive()
return (
<MessageContextProvider>
<View
style={[
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
}),
web({
width: '100%',
minWidth: 280,
maxWidth: 360,
}),
]}>
<View
style={[
a.p_md,
a.gap_md,
a.overflow_hidden,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderBottomLeftRadius: BORDER_RADIUS,
borderTopLeftRadius: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderBottomRightRadius: BORDER_RADIUS,
borderTopRightRadius: BORDER_RADIUS,
},
]}>
<ChatInvite.Root
code={embed.joinLinkPreview.code}
initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
</ChatInvite.Root>
</View>
</View>
</MessageContextProvider>
)
}
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
export {MessageItemInviteEmbed}
+1
View File
@@ -142,6 +142,7 @@ export function AutoSizedImage({
} }
}} }}
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
<MediaInsetBorder /> <MediaInsetBorder />
+39 -1
View File
@@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_ANDROID, IS_WEB} from '#/env'
export * from './const' export * from './const'
export * from './maybeApplyGalleryOffsetStyles' export * from './maybeApplyGalleryOffsetStyles'
@@ -264,6 +264,9 @@ export function Gallery({
aria-label={l`Image gallery, ${images.length} images`} aria-label={l`Image gallery, ${images.length} images`}
horizontal horizontal
pagingEnabled={false} 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} showsHorizontalScrollIndicator={false}
directionalLockEnabled directionalLockEnabled
nestedScrollEnabled nestedScrollEnabled
@@ -340,6 +343,11 @@ export function Gallery({
marginLeft: -insetLeft, marginLeft: -insetLeft,
width, 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={{ contentContainerStyle={{
gap: ITEM_GAP, gap: ITEM_GAP,
@@ -480,8 +488,38 @@ function GalleryImage({
height: e.source.height, height: e.source.height,
}) })
}} }}
useAppleWebpCodec
/> />
{!hideBadges && imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
top: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? ( {(hasAlt || isCropped) && !hideBadges ? (
<View <View
accessible={false} accessible={false}
@@ -4,6 +4,7 @@ import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const' import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween' import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils' import {getOffsetForIndex} from '#/components/images/Gallery/utils'
import {IS_WEB_SAFARI} from '#/env'
const DRAG_THRESHOLD = 3 const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85 const FLICK_DECAY = 0.85
@@ -246,12 +247,64 @@ export function usePointerHandlers({
} }
} }
/*
* Safari does not support `overscroll-behavior`, so a horizontal trackpad
* swipe over the carousel can trigger the browser's back/forward
* navigation gesture. We intercept predominantly-horizontal wheel events
* and apply the scroll ourselves, calling preventDefault to suppress the
* history-nav gesture. Vertical-dominant wheel events are left untouched so
* normal page scroll still works. Chrome/Firefox are covered by the
* `overscrollBehaviorX: 'contain'` style on the FlatList.
*
* Listener must be non-passive so preventDefault is honored.
*/
const onWheel = (e: WheelEvent) => {
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('mousedown', onMouseDown)
el.addEventListener('wheel', onWheel, {passive: false})
window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp) window.addEventListener('mouseup', onMouseUp)
return () => { return () => {
el.removeEventListener('mousedown', onMouseDown) el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('wheel', onWheel)
window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp) window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween() if (stopTween) stopTween()
@@ -109,6 +109,7 @@ export function GalleryItem({
} }
}} }}
loading="lazy" loading="lazy"
useAppleWebpCodec
/> />
<MediaInsetBorder style={insetBorderStyle} /> <MediaInsetBorder style={insetBorderStyle} />
</Pressable> </Pressable>
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {data, error, isLoading} = useJoinLinkPreviewsQuery({ const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined, codes: code ? [code] : undefined,
hasSession, hasSession,
staleTime: 0,
}) })
const {mutate: joinGroupChat, isPending: isJoinPending} = const {mutate: joinGroupChat, isPending: isJoinPending} =
@@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
) { ) {
errorMessage = l`The member limit has been reached.` errorMessage = l`The member limit has been reached.`
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) { } 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) Toast.show(errorMessage)
}, },
@@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
<View <View
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}> style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
<Text <Text
emoji
style={[ style={[
a.mb_2xs, a.mb_2xs,
a.text_center, a.text_center,
@@ -402,7 +404,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
color="primary" color="primary"
disabled={!code} disabled={!code}
style={[a.w_full]}> style={[a.w_full]}>
<ButtonText>Open chat</ButtonText> <ButtonText>
<Trans>Open chat</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} /> <ButtonIcon icon={ArrowRightIcon} />
</Button> </Button>
) : ( ) : (
@@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
} }
accessibilityHint={ accessibilityHint={
joinLinkPreview.requireApproval joinLinkPreview.requireApproval
? l`Request access to join this group chat` ? l`Tap to request access to join this group chat`
: l`Join this group chat` : l`Tap to join this group chat immediately`
} }
size="large" size="large"
color={buttonColor} color={buttonColor}
@@ -87,6 +87,7 @@ function Inner({
alt={_( alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)} )}
useAppleWebpCodec
/> />
</View> </View>
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]} style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover" contentFit="cover"
placeholderContentFit="cover" placeholderContentFit="cover"
useAppleWebpCodec
/> />
<LinearGradient <LinearGradient
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]} style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover" contentFit="cover"
placeholderContentFit="cover" placeholderContentFit="cover"
useAppleWebpCodec
/> />
<LinearGradient <LinearGradient
@@ -54,6 +54,7 @@ export function LinkPreview({
contentFit="cover" contentFit="cover"
onLoad={() => setImageLoadError(false)} onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)} onError={() => setImageLoadError(true)}
useAppleWebpCodec
/> />
)} )}
{linkMeta && (!linkMeta.image || imageLoadError) && ( {linkMeta && (!linkMeta.image || imageLoadError) && (
@@ -147,6 +147,7 @@ export function LiveStatus({
contentFit="cover" contentFit="cover"
style={[a.absolute, a.inset_0]} style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<LiveIndicator <LiveIndicator
size="large" size="large"
+21 -3
View File
@@ -22,6 +22,7 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid' import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher' import * as Hasher from 'multiformats/hashes/hasher'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -178,7 +179,8 @@ export async function post(
writes: writes, writes: writes,
validate: true, validate: true,
}) })
} catch (e: any) { } catch (err) {
const e = err as Error
logger.error(`Failed to create post`, { logger.error(`Failed to create post`, {
safeMessage: e.message, safeMessage: e.message,
}) })
@@ -326,7 +328,10 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all( const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => { imagesDraft.map(async (image, i) => {
logger.debug(`Compressing 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}`) logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime) const res = await uploadBlob(agent, path, mime)
return { return {
@@ -455,6 +460,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 return undefined
} }
@@ -498,6 +513,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
} }
// Returns a transformed version of the object for use in DAG-CBOR. // 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 { function prepareForHashing(v: any): any {
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, // 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, // the API client will convert this for you but we're hashing in the client,
@@ -520,9 +536,10 @@ function prepareForHashing(v: any): any {
// Walk through plain objects // Walk through plain objects
if (isPlainObject(v)) { if (isPlainObject(v)) {
const obj: any = {} const obj: Record<string, unknown> = {}
let pure = true let pure = true
for (const key in v) { for (const key in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let value = v[key] let value = v[key]
// `value` is undefined // `value` is undefined
if (value === undefined) { if (value === undefined) {
@@ -541,6 +558,7 @@ function prepareForHashing(v: any): any {
return v return v
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPlainObject(v: any): boolean { function isPlainObject(v: any): boolean {
if (typeof v !== 'object' || v === null) { if (typeof v !== 'object' || v === null) {
return false return false
+25 -5
View File
@@ -2,11 +2,12 @@ import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
type BskyAgent, type BskyAgent,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} 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 {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
import {downloadAndResize} from '#/lib/media/manip' import {downloadAndResize} from '#/lib/media/manip'
@@ -16,6 +17,7 @@ import {
} from '#/lib/strings/starter-pack' } from '#/lib/strings/starter-pack'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyCustomFeedUrl, isBskyCustomFeedUrl,
isBskyListUrl, isBskyListUrl,
isBskyPostUrl, isBskyPostUrl,
@@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = {
view: AppBskyGraphDefs.StarterPackView view: AppBskyGraphDefs.StarterPackView
} }
type ResolvedChatInvite = {
type: 'chat-invite'
uri: string
code: string
view?: ChatBskyGroupDefs.JoinLinkPreviewView
}
export type ResolvedLink = export type ResolvedLink =
| ResolvedExternalLink | ResolvedExternalLink
| ResolvedPostRecord | ResolvedPostRecord
| ResolvedFeedRecord | ResolvedFeedRecord
| ResolvedListRecord | ResolvedListRecord
| ResolvedStarterPackRecord | ResolvedStarterPackRecord
| ResolvedChatInvite
export class EmbeddingDisabledError extends Error { export class EmbeddingDisabledError extends Error {
constructor() { constructor() {
@@ -141,6 +151,19 @@ export async function resolveLink(
view: res.data.list, 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)) { if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
const parsed = parseStarterPackUri(uri) const parsed = parseStarterPackUri(uri)
if (!parsed) { if (!parsed) {
@@ -261,10 +284,7 @@ export async function imageToThumb(
try { try {
const img = await downloadAndResize({ const img = await downloadAndResize({
uri: imageUri, uri: imageUri,
width: POST_IMG_MAX.width, ...IMAGE_SIZE_CONFIG_2K_1MB,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
timeout: 15e3, timeout: 15e3,
}) })
if (img) { if (img) {
+8 -4
View File
@@ -97,10 +97,14 @@ export const STAGING_FEEDS = [
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`, `feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
] ]
export const POST_IMG_MAX = { export const IMAGE_SIZE_CONFIG_POSTS = {
width: 2000, maxDimension: 4000,
height: 2000, maxSize: 2000000,
size: 1000000, }
export const IMAGE_SIZE_CONFIG_2K_1MB = {
maxDimension: 2000,
maxSize: 1000000,
} }
export const STAGING_LINK_META_PROXY = export const STAGING_LINK_META_PROXY =
+16 -39
View File
@@ -16,24 +16,21 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS} from '#/env'
import {type PickerImage} from './picker.shared' import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types' import {type Dimensions} from './types'
import {convertCdnPreset} from './util' import {convertCdnPreset, getResizedDimensions} from './util'
export async function compressIfNeeded( export async function compressIfNeeded(
img: PickerImage, img: PickerImage,
maxSize: number = POST_IMG_MAX.size, {maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> { ): Promise<PickerImage> {
if (img.size < maxSize) { if (img.size < maxSize) {
return img return img
} }
const resizedImage = await doResize(normalizePath(img.path), { const resizedImage = await doResize(normalizePath(img.path), {
width: img.width, maxDimension,
height: img.height,
mode: 'stretch',
maxSize, maxSize,
}) })
const finalImageMovedPath = await moveToPermanentPath( const finalImageMovedPath = await moveToPermanentPath(
@@ -49,9 +46,7 @@ export async function compressIfNeeded(
export interface DownloadAndResizeOpts { export interface DownloadAndResizeOpts {
uri: string uri: string
width: number maxDimension: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number maxSize: number
timeout: number timeout: number
} }
@@ -67,7 +62,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)
try { try {
return await doResize(path, opts) return await doResize(path, {
maxDimension: opts.maxDimension,
maxSize: opts.maxSize,
})
} finally { } finally {
void safeDeleteAsync(path) void safeDeleteAsync(path)
} }
@@ -188,9 +186,7 @@ export function getImageDim(path: string): Promise<Dimensions> {
// = // =
interface DoResizeOpts { interface DoResizeOpts {
width: number maxDimension: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: 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() // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
// does not work for local files... // does not work for local files...
const imageRes = await manipulateAsync(localUri, [], {}) const imageRes = await manipulateAsync(localUri, [], {})
const newDimensions = getResizedDimensions({ const newDimensions = getResizedDimensions(
width: imageRes.width, {
height: imageRes.height, width: imageRes.width,
}) height: imageRes.height,
},
opts.maxDimension,
)
let minQualityPercentage = 0 let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive let maxQualityPercentage = 101 // exclusive
@@ -388,28 +387,6 @@ async function withTempFile<T>(
} }
} }
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) { async function downloadImage(uri: string, destName: string, timeout: number) {
// Download to a temp path first, then rename with the correct extension // Download to a temp path first, then rename with the correct extension
// based on the response's mimeType. // based on the response's mimeType.
+22 -17
View File
@@ -1,27 +1,28 @@
import {type PickerImage} from './picker.shared' import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types' import {type Dimensions} from './types'
import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' import {
blobToDataUri,
convertCdnPreset,
getDataUriSize,
getResizedDimensions,
} from './util'
export async function compressIfNeeded( export async function compressIfNeeded(
img: PickerImage, img: PickerImage,
maxSize: number, {maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> { ): Promise<PickerImage> {
if (img.size < maxSize) { if (img.size < maxSize) {
return img return img
} }
return await doResize(img.path, { return await doResize(img.path, {
width: img.width, maxDimension,
height: img.height,
mode: 'stretch',
maxSize, maxSize,
}) })
} }
export interface DownloadAndResizeOpts { export interface DownloadAndResizeOpts {
uri: string uri: string
width: number maxDimension: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number maxSize: number
timeout: number timeout: number
} }
@@ -34,7 +35,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
clearTimeout(to) clearTimeout(to)
const dataUri = await blobToDataUri(resBody) 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}) { export async function shareImageModal(_opts: {uri: string}) {
@@ -70,9 +74,7 @@ export async function getImageDim(path: string): Promise<Dimensions> {
// = // =
interface DoResizeOpts { interface DoResizeOpts {
width: number maxDimension: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number maxSize: number
} }
@@ -80,6 +82,9 @@ async function doResize(
dataUri: string, dataUri: string,
opts: DoResizeOpts, opts: DoResizeOpts,
): Promise<PickerImage> { ): Promise<PickerImage> {
const sourceDims = await getImageDim(dataUri)
const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension)
let newDataUri let newDataUri
let minQualityPercentage = 0 let minQualityPercentage = 0
@@ -90,10 +95,10 @@ async function doResize(
(maxQualityPercentage + minQualityPercentage) / 2, (maxQualityPercentage + minQualityPercentage) / 2,
) )
const tempDataUri = await createResizedImage(dataUri, { const tempDataUri = await createResizedImage(dataUri, {
width: opts.width, width: newDimensions.width,
height: opts.height, height: newDimensions.height,
quality: qualityPercentage / 100, quality: qualityPercentage / 100,
mode: opts.mode, mode: 'contain',
}) })
if (getDataUriSize(tempDataUri) < opts.maxSize) { if (getDataUriSize(tempDataUri) < opts.maxSize) {
@@ -111,8 +116,8 @@ async function doResize(
path: newDataUri, path: newDataUri,
mime: 'image/jpeg', mime: 'image/jpeg',
size: getDataUriSize(newDataUri), size: getDataUriSize(newDataUri),
width: opts.width, width: newDimensions.width,
height: opts.height, height: newDimensions.height,
} }
} }
+11 -7
View File
@@ -8,6 +8,7 @@ import ExpoImageCropTool, {
type OpenCropperOptions, type OpenCropperOptions,
} from '@bsky.app/expo-image-crop-tool' } from '@bsky.app/expo-image-crop-tool'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {compressIfNeeded} from './manip' import {compressIfNeeded} from './manip'
import {type PickerImage} from './picker.shared' import {type PickerImage} from './picker.shared'
@@ -28,13 +29,16 @@ async function getFile() {
throw new Error('Failed to get file info') throw new Error('Failed to get file info')
} }
return await compressIfNeeded({ return await compressIfNeeded(
path: file, {
mime: 'image/jpeg', path: file,
size: fileInfo.size, mime: 'image/jpeg',
width: 4288, size: fileInfo.size,
height: 2848, width: 4288,
}) height: 2848,
},
IMAGE_SIZE_CONFIG_2K_1MB,
)
} }
export async function openPicker(): Promise<PickerImage[]> { export async function openPicker(): Promise<PickerImage[]> {
+25
View File
@@ -2,6 +2,31 @@ export function extractDataUriMime(uri: string): string {
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) 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 // Fairly accurate estimate that is more performant
// than decoding and checking length of URI // than decoding and checking length of URI
export function getDataUriSize(uri: string): number { export function getDataUriSize(uri: string): number {
+3 -2
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import psl from 'psl' import {parse} from 'psl'
import TLDs from 'tlds' import TLDs from 'tlds'
import {BSKY_SERVICE} from '#/lib/constants' import {BSKY_SERVICE} from '#/lib/constants'
@@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
return false 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 const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined { export function getChatInviteCodeFromUrl(url: string): string | undefined {
@@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean {
} }
export function splitApexDomain(hostname: string): [string, string] { export function splitApexDomain(hostname: string): [string, string] {
const hostnamep = psl.parse(hostname) const hostnamep = parse(hostname)
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
return ['', hostname] return ['', hostname]
} }
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -411,7 +411,6 @@ function Header({
count?: number count?: number
hasMoreRequests?: boolean hasMoreRequests?: boolean
}) { }) {
const {t: l} = useLingui()
return ( return (
<Layout.Header.Outer> <Layout.Header.Outer>
<Layout.Header.BackButton /> <Layout.Header.BackButton />
@@ -420,15 +419,15 @@ function Header({
{count === undefined ? ( {count === undefined ? (
<Trans>Requests to join</Trans> <Trans>Requests to join</Trans>
) : hasMoreRequests ? ( ) : hasMoreRequests ? (
l({ <Plural
message: `${count}+ requests to join`, value={count}
comment: other="#+ requests to join"
'Displayed when there are more requests to join a group chat than have been loaded', comment="Displayed when there are more requests to join a group chat than have been loaded"
}) />
) : ( ) : (
<Plural <Plural
value={count} value={count}
zero="No requests to join" _0="No requests to join"
one="# request to join" one="# request to join"
other="# requests to join" other="# requests to join"
/> />
@@ -5,8 +5,7 @@ import {
moderateProfile, moderateProfile,
type ModerationOpts, type ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -178,11 +177,7 @@ export function InviteLinkDialog({
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
Group chats can only have a maximum of{' '} Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, { <Plural value={convo.details.memberLimit} other="# people" />.
one: '# person',
other: '# people',
})}
.
</Trans> </Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>
@@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 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 {useEmail} from '#/state/email-verification'
import { import {
useMessageDraft, useMessageDraft,
@@ -233,7 +233,11 @@ export function MessageComposer({
}} }}
onChange={handleChange} onChange={handleChange}
onFacetCommitted={facet => { onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) { if (
facet.type === 'url' &&
(isBskyPostUrl(facet.value) ||
isBskyChatInviteUrl(facet.value))
) {
setEmbed(facet.value) setEmbed(facet.value)
} }
}} }}
@@ -18,6 +18,8 @@ import {
} from '#/lib/routes/types' } from '#/lib/routes/types'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyChatInviteUrl,
isBskyPostUrl, isBskyPostUrl,
makeRecordUri, makeRecordUri,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
@@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview' import * as MediaPreview from '#/components/MediaPreview'
@@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' 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() { export function useMessageEmbed() {
const route = const route =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed const embedFromParams = route.params.embed
const [embedUri, setEmbedUri] = useState(embedFromParams) const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
)
if (embedFromParams && embedUri !== embedFromParams) { if (embedFromParams && embed?.type !== 'post') {
setEmbedUri(embedFromParams) setEmbedState({type: 'post', uri: embedFromParams})
} }
return { return {
embedUri, embed,
setEmbed: useCallback( setEmbed: useCallback(
(embedUrl: string | undefined) => { (embedUrl: string | undefined) => {
if (!embedUrl) { 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: ''}) navigation.setParams({embed: ''})
setEmbedUri(undefined) setEmbedState(undefined)
return return
} }
if (embedFromParams) return if (embedFromParams) return
const url = convertBskyAppUrlIfNeeded(embedUrl) if (isBskyChatInviteUrl(embedUrl)) {
const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const code = getChatInviteCodeFromUrl(embedUrl)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) 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], [embedFromParams, navigation],
), ),
@@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets(
for (const facet of rt.facets ?? []) { for (const facet of rt.facets ?? []) {
for (const feature of facet.features) { 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 uriFromFacet = feature.uri
break break
} }
@@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets(
} }
export function MessageInputEmbed({ export function MessageInputEmbed({
embedUri, embed,
setEmbed, setEmbed,
}: { }: {
embedUri: string | undefined embed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void 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 <MessageInputPostEmbed uri={embed.uri} onRemove={onRemove} />
case 'invite':
return <MessageInputInviteEmbed code={embed.code} onRemove={onRemove} />
}
}
function MessageInputPostEmbed({
uri,
onRemove,
}: {
uri: string
onRemove: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {data: post, status} = usePostQuery(embedUri) const {data: post, status} = usePostQuery(uri)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = useMemo( const moderation = useMemo(
@@ -134,15 +185,6 @@ export function MessageInputEmbed({
return {rt: undefined, record: undefined} return {rt: undefined, record: undefined}
}, [post]) }, [post])
if (!embedUri) {
return null
}
const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setEmbed(undefined)
}
switch (status) { switch (status) {
case 'pending': { case 'pending': {
return ( return (
@@ -220,6 +262,71 @@ export function MessageInputEmbed({
} }
} }
function MessageInputInviteEmbed({
code,
onRemove,
}: {
code: string
onRemove: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<ChatInvite.Root code={code}>
<View
style={[
a.flex_1,
t.atoms.border_contrast_high,
a.rounded_md,
a.border,
a.p_sm,
a.mt_sm,
a.mx_sm,
]}>
<MessageInputInviteEmbedBody />
<Button
label={l`Remove embed`}
onPress={onRemove}
style={[
a.absolute,
{top: 10, right: 8},
a.px_2xs,
{transform: [{translateY: -2}]},
]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
</View>
</ChatInvite.Root>
)
}
function MessageInputInviteEmbedBody() {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Loader />
</View>
)
}
if (!preview) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
<Trans>Could not load invite</Trans>
</Text>
</View>
)
}
return <ChatInvite.Card size="small" />
}
function SimpleContainer({ function SimpleContainer({
children, children,
onRemove, onRemove,
@@ -28,6 +28,7 @@ import {
type AppBskyEmbedRecord, type AppBskyEmbedRecord,
AppBskyRichtextFacet, AppBskyRichtextFacet,
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyEmbedJoinLink,
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' 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 {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyPostUrl, isBskyPostUrl,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -46,9 +48,10 @@ import {
useConvoActive, useConvoActive,
} from '#/state/messages/convo' } from '#/state/messages/convo'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types' import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' 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 {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageInput} from '#/screens/Messages/components/MessageInput'
@@ -131,8 +134,10 @@ export function MessagesList({
const ax = useAnalytics() const ax = useAnalytics()
const convoState = useConvoActive() const convoState = useConvoActive()
const agent = useAgent() const agent = useAgent()
const {hasSession} = useSession()
const getPost = useGetPost() const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed() const getJoinLinkPreview = useGetJoinLinkPreview()
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
const t = useTheme() const t = useTheme()
const textInputId = 'chat-input-' + useId() 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 // we want to remove the post link from the text, re-trim, then detect facets
rt.detectFacetsWithoutResolution() rt.detectFacetsWithoutResolution()
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined let embed:
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined | $Typed<AppBskyEmbedRecord.Main>
| $Typed<ChatBskyEmbedJoinLink.Main>
| undefined
let embedView:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| 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 { try {
const post = await getPost({uri: embedUri}) const post = await getPost({uri: messageEmbed.uri})
if (post) { if (post) {
embed = { embed = {
$type: 'app.bsky.embed.record', $type: 'app.bsky.embed.record',
@@ -368,42 +399,34 @@ export function MessagesList({
record: createEmbedViewRecordFromPost(post), record: createEmbedViewRecordFromPost(post),
} }
// look for the embed uri in the facets, so we can remove it from the text stripLinkFacet(uri => {
const postLinkFacet = rt.facets?.find(facet => { if (!isBskyPostUrl(uri)) return false
return facet.features.find(feature => { const url = convertBskyAppUrlIfNeeded(uri)
if (AppBskyRichtextFacet.isLink(feature)) { const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
if (isBskyPostUrl(feature.uri)) { // this might have a handle instead of a DID
const url = convertBskyAppUrlIfNeeded(feature.uri) // so just compare the rkey - not particularly dangerous
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) return post.uri.endsWith(rkey)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
}
}
return false
})
}) })
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) { } catch (error) {
logger.error('Failed to get post as quote for DM', {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) await rt.detectFacets(agent)
@@ -424,7 +447,16 @@ export function MessagesList({
embedView, embedView,
) )
}, },
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled], [
agent,
convoState,
messageEmbed,
getPost,
getJoinLinkPreview,
hasSession,
hasScrolled,
setHasScrolled,
],
) )
const scrollToEndOnPress = useCallback(() => { const scrollToEndOnPress = useCallback(() => {
@@ -595,11 +627,11 @@ export function MessagesList({
onSendMessage={(message: string) => onSendMessage={(message: string) =>
void onSendMessage(message) void onSendMessage(message)
} }
hasEmbed={!!embedUri} hasEmbed={!!messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading}> loading={loading}>
<MessageInputEmbed <MessageInputEmbed
embedUri={embedUri} embed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
/> />
</MessageComposer> </MessageComposer>
@@ -607,11 +639,11 @@ export function MessagesList({
<MessageInput <MessageInput
textInputId={textInputId} textInputId={textInputId}
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
hasEmbed={!!embedUri} hasEmbed={!!messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading}> loading={loading}>
<MessageInputEmbed <MessageInputEmbed
embedUri={embedUri} embed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
/> />
</MessageInput> </MessageInput>
@@ -83,6 +83,7 @@ function Page({
style={[a.w_full, a.aspect_square]} style={[a.w_full, a.aspect_square]}
alt={alt} alt={alt}
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
useAppleWebpCodec
/> />
{page === 1 && ( {page === 1 && (
<Image <Image
@@ -97,6 +98,7 @@ function Page({
}, },
]} ]}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
alt={_(msg`Your profile picture`)} alt={_(msg`Your profile picture`)}
/> />
)} )}
+2 -1
View File
@@ -19,6 +19,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {compressIfNeeded} from '#/lib/media/manip' import {compressIfNeeded} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker' 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, // If we are on mobile, prefetching the image will load the image into memory before we try and display it,
// stopping any brief flickers. // stopping any brief flickers.
@@ -105,6 +105,7 @@ function GermLogo({size}: {size: 'small' | 'large'}) {
source={require('../../../../assets/images/germ_logo.webp')} source={require('../../../../assets/images/germ_logo.webp')}
accessibilityIgnoresInvertColors={false} accessibilityIgnoresInvertColors={false}
contentFit="cover" contentFit="cover"
useAppleWebpCodec
style={[ style={[
a.rounded_full, a.rounded_full,
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16}, size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
@@ -1,9 +1,9 @@
import {useState} from 'react' import {useState} from 'react'
import {Alert, View} from 'react-native' import {Alert, View} from 'react-native'
import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {PressableScale} from '#/lib/custom-animations/PressableScale' import {PressableScale} from '#/lib/custom-animations/PressableScale'
@@ -1,5 +1,5 @@
import {type ImageSourcePropType} from 'react-native' 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 = { export type AppIconSet = {
id: DynamicAppIcon.IconName id: DynamicAppIcon.IconName
@@ -1,5 +1,5 @@
import {useCallback, useMemo, useState} from 'react' 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 {useFocusEffect} from '@react-navigation/native'
import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets'
+16 -6
View File
@@ -201,12 +201,17 @@ export function resetImageManipulation(
return img return img
} }
export async function compressImage(img: ComposerImage): Promise<PickerImage> { export async function compressImage(
img: ComposerImage,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> {
const source = img.transformed || img.source const source = img.transformed || img.source
let attempts = 0 let attempts = 0
let maxDimension = 4000 // Seeded from `maxDimension` but shrunk per attempt below, so keep the
let maxBytes = 2000000 // passed-in value pristine.
let currentDimension = maxDimension
const maxBytes = maxSize
let minQualityPercentage = 0 let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive let maxQualityPercentage = 101 // exclusive
@@ -215,7 +220,11 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
while (maxQualityPercentage - minQualityPercentage > 1) { while (maxQualityPercentage - minQualityPercentage > 1) {
if (attempts >= 4) break 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( const qualityPercentage = Math.round(
(maxQualityPercentage + minQualityPercentage) / 2, (maxQualityPercentage + minQualityPercentage) / 2,
) )
@@ -230,8 +239,9 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
minQualityPercentage = 0 minQualityPercentage = 0
maxQualityPercentage = 101 maxQualityPercentage = 101
attempts++ attempts++
// 4000px → 3200px → 2560px → 2048px → ~1638px // max.width → 0.8× → 0.64× → 0.512× → ~0.41×
maxDimension = Math.floor(maxDimension * 0.8) // e.g. 4000px → 3200px → 2560px → 2048px → ~1638px
currentDimension = Math.floor(currentDimension * 0.8)
continue continue
} }
+7 -2
View File
@@ -6,6 +6,7 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyConvoGetLog, type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage, type ChatBskyConvoSendMessage,
type ChatBskyEmbedJoinLink,
type ChatBskyGroupDefs, type ChatBskyGroupDefs,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/api' import {XRPCError} from '@atproto/api'
@@ -109,7 +110,9 @@ export class Convo {
{ {
id: string id: string
message: ChatBskyConvoSendMessage.InputSchema['message'] message: ChatBskyConvoSendMessage.InputSchema['message']
optimisticEmbedView?: $Typed<AppBskyEmbedRecord.View> optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
} }
> = new Map() > = new Map()
private deletedMessages: Set<string> = new Set() private deletedMessages: Set<string> = new Set()
@@ -942,7 +945,9 @@ export class Convo {
sendMessage( sendMessage(
message: ChatBskyConvoSendMessage.InputSchema['message'], message: ChatBskyConvoSendMessage.InputSchema['message'],
optimisticEmbedView?: $Typed<AppBskyEmbedRecord.View>, optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>,
) { ) {
// Ignore empty messages for now since they have no other purpose atm // Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim() && !message.embed) return if (!message.text.trim() && !message.embed) return
+5 -1
View File
@@ -5,6 +5,7 @@ import {
type ChatBskyActorDefs, type ChatBskyActorDefs,
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
type ChatBskyConvoSendMessage, type ChatBskyConvoSendMessage,
type ChatBskyEmbedJoinLink,
} from '@atproto/api' } from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBus} from '#/state/messages/events/agent'
@@ -108,7 +109,10 @@ export type ConvoItem =
type DeleteMessage = (messageId: string) => Promise<void> type DeleteMessage = (messageId: string) => Promise<void>
type SendMessage = ( type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'], message: ChatBskyConvoSendMessage.InputSchema['message'],
optimisticEmbedView: $Typed<AppBskyEmbedRecord.View> | undefined, optimisticEmbedView:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| undefined,
) => void ) => void
type FetchMessageHistory = () => Promise<void> type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void type MarkConvoAccepted = () => void
+73 -22
View File
@@ -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 {useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants' import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
@@ -17,12 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: {
persistedVersion: 1, persistedVersion: 1,
}) })
export function useJoinLinkPreviewsQuery({ async function fetchJoinLinkPreviews({
agent,
codes, codes,
hasSession, 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[] codes?: string[]
hasSession: boolean 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() const agent = useAgent()
@@ -31,21 +63,15 @@ export function useJoinLinkPreviewsQuery({
queryFn: async () => { queryFn: async () => {
if (!codes) throw new Error('No invite code') if (!codes) throw new Error('No invite code')
try { try {
const previewAgent = new AtpAgent({service: CHAT_SERVICE}) return await fetchJoinLinkPreviews({agent, codes, hasSession})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
} catch (error) { } catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error}) logger.error('Failed to fetch join link preview', {safeMessage: error})
throw error throw error
} }
}, },
enabled: codes != null && codes.length > 0, 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 ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
return queryClient.prefetchQuery({ return queryClient.prefetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}), queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
queryFn: async () => { queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
const previewAgent = new AtpAgent({service: CHAT_SERVICE}) staleTime: STALE.MINUTES.ONE,
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
},
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<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => {
try {
const data = await queryClient.fetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
queryFn: () =>
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
staleTime: STALE.MINUTES.ONE,
})
return data.joinLinkPreviews[0]
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
return undefined
}
},
[agent, queryClient],
)
}
+9 -7
View File
@@ -39,14 +39,16 @@ export function useJoinRequestMutation<A extends JoinRequestAction>(
return useMutation({ return useMutation({
mutationFn: async ({member}: {member: string}) => { mutationFn: async ({member}: {member: string}) => {
if (!convoId) throw new Error('No convoId provided') if (!convoId) throw new Error('No convoId provided')
const endpoint = const {data} =
action === 'approve' action === 'approve'
? agent.chat.bsky.group.approveJoinRequest ? await agent.chat.bsky.group.approveJoinRequest(
: agent.chat.bsky.group.rejectJoinRequest {convoId, member},
const {data} = await endpoint( {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
{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<A> return data as JoinRequestOutput<A>
}, },
onMutate: ({member}) => { onMutate: ({member}) => {
@@ -1,6 +1,5 @@
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger'
import {device} from '#/storage' import {device} from '#/storage'
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil
@@ -77,8 +76,6 @@ export function configureAdditionalModerationAuthorities() {
if (geolocation?.countryCode) { if (geolocation?.countryCode) {
// overwrite with only those necessary // overwrite with only those necessary
additionalLabelers = MODERATION_AUTHORITIES[geolocation.countryCode] ?? [] additionalLabelers = MODERATION_AUTHORITIES[geolocation.countryCode] ?? []
} else {
logger.info(`no geolocation, cannot apply mod authorities`)
} }
if (__DEV__) { if (__DEV__) {
@@ -89,10 +86,5 @@ export function configureAdditionalModerationAuthorities() {
new Set([...BskyAgent.appLabelers, ...additionalLabelers]), new Set([...BskyAgent.appLabelers, ...additionalLabelers]),
) )
logger.info(`applying mod authorities`, {
additionalLabelers,
appLabelers,
})
BskyAgent.configure({appLabelers}) BskyAgent.configure({appLabelers})
} }
+10 -10
View File
@@ -171,8 +171,8 @@ function ComposerReplyToImages({
<Image <Image
source={{uri: images[0].thumb}} source={{uri: images[0].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
)) || )) ||
(images.length === 2 && ( (images.length === 2 && (
@@ -180,14 +180,14 @@ function ComposerReplyToImages({
<Image <Image
source={{uri: images[0].thumb}} source={{uri: images[0].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<Image <Image
source={{uri: images[1].thumb}} source={{uri: images[1].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
</View> </View>
)) || )) ||
@@ -196,21 +196,21 @@ function ComposerReplyToImages({
<Image <Image
source={{uri: images[0].thumb}} source={{uri: images[0].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Image <Image
source={{uri: images[1].thumb}} source={{uri: images[1].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<Image <Image
source={{uri: images[2].thumb}} source={{uri: images[2].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
@@ -221,28 +221,28 @@ function ComposerReplyToImages({
<Image <Image
source={{uri: images[0].thumb}} source={{uri: images[0].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<Image <Image
source={{uri: images[1].thumb}} source={{uri: images[1].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
</View> </View>
<View style={[a.flex_1, a.flex_row, a.gap_2xs]}> <View style={[a.flex_1, a.flex_row, a.gap_2xs]}>
<Image <Image
source={{uri: images[2].thumb}} source={{uri: images[2].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
<Image <Image
source={{uri: images[3].thumb}} source={{uri: images[3].thumb}}
style={[a.flex_1]} style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
</View> </View>
</View> </View>
+3
View File
@@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
@@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({
hideAlt hideAlt
/> />
) )
} else if (data.type === 'chat-invite') {
return <JoinRequestEmbed code={data.code} preview={data.view} />
} else if (data.kind === 'feed') { } else if (data.kind === 'feed') {
return ( return (
<ModeratedFeedEmbed <ModeratedFeedEmbed
@@ -1,6 +1,5 @@
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
@@ -11,12 +10,12 @@ export function ExternalEmbedRemoveBtn({
style, style,
}: {onRemove: () => void} & ViewStyleProp) { }: {onRemove: () => void} & ViewStyleProp) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}> <View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}>
<Button <Button
label={_(msg`Remove attachment`)} label={l`Remove attachment`}
onPress={onRemove} onPress={onRemove}
size="small" size="small"
variant="solid" variant="solid"
+1
View File
@@ -247,6 +247,7 @@ const GalleryItem = ({
}} }}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
enforceEarlyResizing
cachePolicy="none" cachePolicy="none"
autoplay={false} autoplay={false}
contentFit="cover" contentFit="cover"
@@ -3,7 +3,6 @@ import * as MediaLibrary from 'expo-media-library'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {POST_IMG_MAX} from '#/lib/constants'
import {useCameraPermission} from '#/lib/hooks/usePermissions' import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker' import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -35,7 +34,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
} }
const img = await openCamera({ const img = await openCamera({
aspect: [POST_IMG_MAX.width, POST_IMG_MAX.height], aspect: [1, 1],
}) })
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
@@ -16,7 +16,7 @@ import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input'
import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {POST_IMG_MAX} from '#/lib/constants' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {downloadAndResize} from '#/lib/media/manip' import {downloadAndResize} from '#/lib/media/manip'
import {isUriImage} from '#/lib/media/util' import {isUriImage} from '#/lib/media/util'
import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
@@ -93,10 +93,7 @@ export function TextInput({
if (isUriImage(feature.uri)) { if (isUriImage(feature.uri)) {
const res = await downloadAndResize({ const res = await downloadAndResize({
uri: feature.uri, uri: feature.uri,
width: POST_IMG_MAX.width, ...IMAGE_SIZE_CONFIG_POSTS,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
timeout: 15e3, timeout: 15e3,
}) })
+5 -1
View File
@@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import { import {
useCameraPermission, useCameraPermission,
@@ -330,6 +331,7 @@ let UserAvatar = ({
}} }}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
onLoad={onLoad} onLoad={onLoad}
useAppleWebpCodec
/> />
)} )}
{!noBorder && <MediaInsetBorder style={borderStyle} />} {!noBorder && <MediaInsetBorder style={borderStyle} />}
@@ -394,6 +396,7 @@ let EditableUserAvatar = ({
await openCamera({ await openCamera({
aspect: [1, 1], aspect: [1, 1],
}), }),
IMAGE_SIZE_CONFIG_2K_1MB,
), ),
) )
}, [onSelectNewAvatar, requestCameraAccessIfNeeded]) }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
@@ -422,6 +425,7 @@ let EditableUserAvatar = ({
shape: circular ? 'circle' : 'rectangle', shape: circular ? 'circle' : 'rectangle',
aspectRatio: 1, aspectRatio: 1,
}), }),
IMAGE_SIZE_CONFIG_2K_1MB,
), ),
) )
} else { } else {
@@ -448,7 +452,7 @@ let EditableUserAvatar = ({
const onChangeEditImage = useCallback( const onChangeEditImage = useCallback(
async (image: ComposerImage) => { async (image: ComposerImage) => {
const compressed = await compressImage(image) const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB)
onSelectNewAvatar(compressed) onSelectNewAvatar(compressed)
}, },
[onSelectNewAvatar], [onSelectNewAvatar],
+6 -1
View File
@@ -6,6 +6,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import { import {
useCameraPermission, useCameraPermission,
usePhotoLibraryPermission, usePhotoLibraryPermission,
@@ -62,6 +63,7 @@ export function UserBanner({
await openCamera({ await openCamera({
aspect: [3, 1], aspect: [3, 1],
}), }),
IMAGE_SIZE_CONFIG_2K_1MB,
), ),
) )
}, [onSelectNewBanner, requestCameraAccessIfNeeded]) }, [onSelectNewBanner, requestCameraAccessIfNeeded])
@@ -83,6 +85,7 @@ export function UserBanner({
imageUri: items[0].path, imageUri: items[0].path,
aspectRatio: 3 / 1, aspectRatio: 3 / 1,
}), }),
IMAGE_SIZE_CONFIG_2K_1MB,
), ),
) )
} else { } else {
@@ -108,7 +111,7 @@ export function UserBanner({
const onChangeEditImage = useCallback( const onChangeEditImage = useCallback(
async (image: ComposerImage) => { async (image: ComposerImage) => {
const compressed = await compressImage(image) const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB)
onSelectNewBanner?.(compressed) onSelectNewBanner?.(compressed)
}, },
[onSelectNewBanner], [onSelectNewBanner],
@@ -129,6 +132,7 @@ export function UserBanner({
source={{uri: banner}} source={{uri: banner}}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
) : ( ) : (
<View <View
@@ -216,6 +220,7 @@ export function UserBanner({
blurRadius={moderation?.blur ? 100 : 0} blurRadius={moderation?.blur ? 100 : 0}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
useAppleWebpCodec
/> />
) : ( ) : (
<View <View
+3 -2
View File
@@ -144,6 +144,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const [demoMode] = useDemoMode() const [demoMode] = useDemoMode()
const {isActive: live} = useActorStatus(profile) const {isActive: live} = useActorStatus(profile)
const isLabeler = profile?.associated?.labeler
return ( return (
<> <>
@@ -276,9 +277,9 @@ export function BottomBar({navigation}: BottomTabBarProps) {
<View <View
style={[ style={[
styles.ctrlIcon, styles.ctrlIcon,
styles.profileIcon, isLabeler ? styles.profileIconSquare : styles.profileIcon,
isAtMyProfile && [ isAtMyProfile && [
styles.onProfile, isLabeler ? styles.onProfileSquare : styles.onProfile,
{ {
borderColor: t.atoms.text.color, borderColor: t.atoms.text.color,
borderWidth: live ? 0 : 1, borderWidth: live ? 0 : 1,
@@ -67,9 +67,18 @@ export const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: 'transparent', borderColor: 'transparent',
}, },
profileIconSquare: {
borderRadius: 3,
borderWidth: 1,
borderColor: 'transparent',
},
messagesIcon: {}, messagesIcon: {},
onProfile: { onProfile: {
borderWidth: 1, borderWidth: 1,
borderRadius: 100, borderRadius: 100,
}, },
onProfileSquare: {
borderWidth: 1,
borderRadius: 3,
},
}) })
+7 -2
View File
@@ -65,6 +65,7 @@ export function BottomBarWeb() {
const unreadMessageCount = useUnreadMessageCount() const unreadMessageCount = useUnreadMessageCount()
const notificationCountStr = useUnreadNotifications() const notificationCountStr = useUnreadNotifications()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const isLabeler = profile?.associated?.labeler
const showSignIn = useCallback(() => { const showSignIn = useCallback(() => {
closeAllActiveElements() closeAllActiveElements()
@@ -186,9 +187,13 @@ export function BottomBarWeb() {
<View <View
style={[ style={[
styles.ctrlIcon, styles.ctrlIcon,
styles.profileIcon, isLabeler
? styles.profileIconSquare
: styles.profileIcon,
isActive && [ isActive && [
styles.onProfile, isLabeler
? styles.onProfileSquare
: styles.onProfile,
{borderColor: t.atoms.text.color}, {borderColor: t.atoms.text.color},
], ],
]}> ]}>
+1
View File
@@ -175,6 +175,7 @@ function ProfileCard({minimal}: {minimal: boolean}) {
}, },
]}> ]}>
<Text <Text
emoji
style={[a.font_bold, a.text_sm, a.leading_snug]} style={[a.font_bold, a.text_sm, a.leading_snug]}
numberOfLines={1}> numberOfLines={1}>
{sanitizeDisplayName( {sanitizeDisplayName(