Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3363f61e | |||
| c9e94e9b14 | |||
| 923e1635d8 | |||
| ded2ca3744 | |||
| 64890cc628 | |||
| 09fa93553b | |||
| b9637c0305 | |||
| 6677ae977c | |||
| 0da63326bd | |||
| 58783ca5ae | |||
| d3c9ed027c | |||
| 795d493dea | |||
| 66db349ef2 | |||
| 08cd4e58b0 | |||
| c79b5bfa08 | |||
| 49f8e5031f | |||
| 9f200aab05 | |||
| a19d652a39 | |||
| ad4591eb29 | |||
| 77c08164b7 | |||
| 06298eb58a | |||
| af2eec0f84 | |||
| 153772b760 | |||
| 4fe5f9da37 | |||
| a222ec4de0 | |||
| 6ac2e30c8f | |||
| 9aea4362d5 |
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
|
||||
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||
import {
|
||||
downloadAndResize,
|
||||
type DownloadAndResizeOpts,
|
||||
getResizedDimensions,
|
||||
} from '../../src/lib/media/manip'
|
||||
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||
|
||||
const mockResizedImage = {
|
||||
path: 'file://resized-image.jpg',
|
||||
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
|
||||
|
||||
// First time it gets called is to get dimensions
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
||||
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||
// downsized.
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[{resize: {height: opts.height, width: opts.width}}],
|
||||
[{resize: {height: 100, width: 100}}],
|
||||
{format: SaveFormat.JPEG, compress: 1.0},
|
||||
)
|
||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
|
||||
it('should return undefined for invalid URI', async () => {
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'invalid-uri',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
|
||||
width: 1200,
|
||||
height: 1000,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 1000,
|
||||
height: 1200,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
||||
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
||||
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
|
||||
width: 3000,
|
||||
height: 1500,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 2000,
|
||||
height: 4000,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual({
|
||||
width: 2000,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
getChatInviteCodeFromUrl,
|
||||
isPossiblyAUrl,
|
||||
isTrustedUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
|
||||
expect(output).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatInviteCodeFromUrl', () => {
|
||||
type Case = [string, string | undefined]
|
||||
|
||||
const cases: Case[] = [
|
||||
['https://bsky.app/c/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
|
||||
// http is not recognized as a bsky.app url
|
||||
['http://bsky.app/c/abcdefg', undefined],
|
||||
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
|
||||
['/c/abcdefg', 'abcdefg'],
|
||||
['/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['/c/abcdefg#section', 'abcdefg'],
|
||||
|
||||
// too short
|
||||
['https://bsky.app/c/abcdef', undefined],
|
||||
['/c/abcdef', undefined],
|
||||
// too long
|
||||
['https://bsky.app/c/abcdefghijk', undefined],
|
||||
['/c/abcdefghijk', undefined],
|
||||
// invalid characters
|
||||
['https://bsky.app/c/abc-def', undefined],
|
||||
['/c/abc def', undefined],
|
||||
// trailing path
|
||||
['https://bsky.app/c/abcdefg/extra', undefined],
|
||||
['/c/abcdefg/extra', undefined],
|
||||
// wrong path
|
||||
['https://bsky.app/profile/abcdefg', undefined],
|
||||
['https://bsky.app/c', undefined],
|
||||
// wrong host
|
||||
['https://example.com/c/abcdefg', undefined],
|
||||
// not a url, not a path
|
||||
['c/abcdefg', undefined],
|
||||
['abcdefg', undefined],
|
||||
['', undefined],
|
||||
// malformed url
|
||||
['https://[invalid/c/abcdefg', undefined],
|
||||
]
|
||||
|
||||
it.each(cases)('given input %p, returns %p', (input, expected) => {
|
||||
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -349,7 +349,7 @@ module.exports = function (_config) {
|
||||
},
|
||||
],
|
||||
[
|
||||
'@mozzius/expo-dynamic-app-icon',
|
||||
'@bsky.app/expo-dynamic-app-icon',
|
||||
{
|
||||
/**
|
||||
* Default set
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
}
|
||||
},
|
||||
"src/analytics/PassiveAnalytics.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -124,11 +121,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Button.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Composer/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -261,11 +253,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -811,14 +798,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/api/index.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/lib/async/retry.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
+2
-2
@@ -93,11 +93,12 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.8",
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
@@ -123,7 +124,6 @@
|
||||
"@ipld/dag-cbor": "^9.2.7",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
|
||||
Generated
+23
-23
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.8
|
||||
version: 0.20.8
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -256,6 +256,9 @@ importers:
|
||||
'@bsky.app/alf':
|
||||
specifier: ^0.1.14
|
||||
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.5
|
||||
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-guess-language':
|
||||
specifier: ^0.2.8
|
||||
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -331,9 +334,6 @@ importers:
|
||||
'@lingui/react':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
|
||||
'@mozzius/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1624,6 +1624,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5':
|
||||
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8':
|
||||
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
|
||||
peerDependencies:
|
||||
@@ -2327,13 +2334,6 @@ packages:
|
||||
'@messageformat/parser@5.1.1':
|
||||
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1':
|
||||
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -9493,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
'@atproto/api@0.20.9':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10446,6 +10446,14 @@ snapshots:
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-responsive: 10.0.1(react@19.1.0)
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -11449,14 +11457,6 @@ snapshots:
|
||||
dependencies:
|
||||
moo: 0.5.3
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
|
||||
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
|
||||
})
|
||||
}
|
||||
|
||||
if (IS_DEV || IS_TESTFLIGHT) {
|
||||
const feats = Object.values(Features).reduce(
|
||||
(acc, feat) => {
|
||||
acc[feat] = features.evalFeature(feat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<Features, any>,
|
||||
)
|
||||
ax.logger.info('FEATURES', {
|
||||
features: feats,
|
||||
definitions: features.getFeatures(),
|
||||
})
|
||||
}
|
||||
// if (IS_DEV || IS_TESTFLIGHT) {
|
||||
// const feats = Object.values(Features).reduce(
|
||||
// (acc, feat) => {
|
||||
// acc[feat] = features.evalFeature(feat)
|
||||
// return acc
|
||||
// },
|
||||
// {} as Record<Features, any>,
|
||||
// )
|
||||
// ax.logger.info('FEATURES', {
|
||||
// features: feats,
|
||||
// definitions: features.getFeatures(),
|
||||
// })
|
||||
// }
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
|
||||
}
|
||||
|
||||
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
||||
logger.debug(`sendBatch: ${events.length}`, {
|
||||
isRetry,
|
||||
})
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({events})
|
||||
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export type ButtonColor =
|
||||
| 'negative'
|
||||
| 'primary_subtle'
|
||||
| 'negative_subtle'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
variant: variantProp,
|
||||
color,
|
||||
size,
|
||||
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
|
||||
* "solid" buttons. This is to maintain backwards compatibility.
|
||||
*/
|
||||
if (!variant && color) {
|
||||
let variant: VariantProps['variant'] = variantProp
|
||||
if (!variantProp && color) {
|
||||
variant = 'solid'
|
||||
}
|
||||
|
||||
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
paddingHorizontal: 24,
|
||||
gap: 6,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 28,
|
||||
gap: 5,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 8,
|
||||
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
borderRadius: 10,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 8,
|
||||
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
} else {
|
||||
baseStyles.push({height: 44, width: 44})
|
||||
}
|
||||
} else if (size === 'medium') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
} else {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.font_medium)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'tiny') {
|
||||
@@ -799,6 +821,7 @@ export function ButtonIcon({
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
medium: 'sm',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
@@ -828,6 +851,7 @@ export function ButtonIcon({
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
medium: 17,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
@@ -841,6 +865,7 @@ export function ButtonIcon({
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
medium: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
@@ -499,6 +499,7 @@ function TriggerClone({
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(msg`The subject of the context menu`)}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
cachePolicy="none"
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
@@ -226,17 +226,21 @@ function LightboxGallery({
|
||||
)}
|
||||
</View>
|
||||
{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={[
|
||||
a.px_4xl,
|
||||
a.py_2xl,
|
||||
styles.altScroll,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
// @ts-expect-error web only
|
||||
backdropFilter: 'blur(16px)',
|
||||
},
|
||||
delayedFadeInAnim,
|
||||
]}>
|
||||
]}
|
||||
scrollEnabled={isAltExpanded}
|
||||
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
|
||||
<Pressable
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
|
||||
@@ -250,7 +254,7 @@ function LightboxGallery({
|
||||
{img.alt}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{imgs.length > 1 && (
|
||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
altScroll: {
|
||||
// Size to content like the View it replaced, rather than filling the
|
||||
// column via ScrollView's default flexGrow.
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
// @ts-ignore web-only -sfn
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
menuBtn: {
|
||||
top: 20,
|
||||
left: 20,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {useRef} from 'react'
|
||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {height: screenHeight} = useSafeAreaFrame()
|
||||
const isMomentumScrolling = useRef(false)
|
||||
|
||||
if (!altText) return null
|
||||
|
||||
// Cap the overlay height so long alt text - or text enlarged by the OS via
|
||||
// Dynamic Type / font scaling - scrolls within the overlay instead of growing
|
||||
// past the top of the screen. Leaves the upper half clear for the header.
|
||||
const maxHeight = screenHeight / 2
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -46,6 +55,7 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
}),
|
||||
]}>
|
||||
<ScrollView
|
||||
style={{maxHeight}}
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {BlurView} from 'expo-blur'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
@@ -14,26 +13,38 @@ const GAP = 5
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
||||
{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 style={styles.root}>
|
||||
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||
{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,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</BlurView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
root: {
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
|
||||
@@ -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"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -32,6 +32,7 @@ import Animated, {
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
@@ -136,6 +137,9 @@ export default function ImageViewRoot({
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
@@ -47,6 +51,34 @@ export function Embed({
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'gallery') {
|
||||
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
|
||||
// a 10-image gallery doesn't blow out the row width. Single pass instead
|
||||
// of filter().slice().map() so we stop at 4 viewable items rather than
|
||||
// walking every item in a 10-image gallery.
|
||||
const tiles: React.ReactNode[] = []
|
||||
for (const item of e.view.items) {
|
||||
if (tiles.length >= 4) break
|
||||
if (!AppBskyEmbedGallery.isViewImage(item)) continue
|
||||
if (peekable) {
|
||||
const image: AppBskyEmbedImages.ViewImage = {
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
|
||||
} else {
|
||||
tiles.push(
|
||||
<ImageItem
|
||||
key={item.thumbnail}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
return <Outer style={style}>{tiles}</Outer>
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isGifEmbed(e.view.external.uri)) return null
|
||||
@@ -129,6 +161,7 @@ export function ImageItem({
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder style={[a.rounded_xs]} />
|
||||
{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(() => {
|
||||
if (link.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(link.uri)
|
||||
void shareUrl(link.uri)
|
||||
}
|
||||
}, [link.uri, playHaptic])
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ExternalEmbed = ({
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useRef} from 'react'
|
||||
import {InteractionManager, View} from 'react-native'
|
||||
import {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
@@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
import {type CommonProps} from './types'
|
||||
|
||||
const MAX_GRID_IMAGES = 4
|
||||
|
||||
export function ImageEmbed({
|
||||
embed,
|
||||
...rest
|
||||
}: CommonProps & {
|
||||
embed: EmbedType<'images'>
|
||||
embed: EmbedType<'images'> | EmbedType<'gallery'>
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const {images} = embed.view
|
||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
const images: AppBskyEmbedImages.ViewImage[] =
|
||||
embed.type === 'gallery'
|
||||
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}))
|
||||
: embed.view.images
|
||||
const useExpandedLayout =
|
||||
embed.type === 'gallery'
|
||||
? images.length > MAX_GRID_IMAGES
|
||||
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
@@ -109,7 +123,7 @@ export function ImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
if (galleryEnabled) {
|
||||
if (useExpandedLayout) {
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<Gallery
|
||||
@@ -130,6 +144,7 @@ export function ImageEmbed({
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -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}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -355,6 +356,7 @@ export function PublicationCard({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -385,7 +387,7 @@ export function PublicationCard({
|
||||
<View style={[a.pointer_events_none]}>
|
||||
{view.description && (
|
||||
<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}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -616,6 +618,7 @@ export function PublicationFooter({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
type EmbedType,
|
||||
parseEmbed,
|
||||
} from '#/types/bsky/post'
|
||||
import {ChatInviteEmbed} from './ChatInviteEmbed'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
@@ -52,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
||||
|
||||
switch (embed.type) {
|
||||
case 'images':
|
||||
case 'gallery':
|
||||
case 'link':
|
||||
case 'video': {
|
||||
return <MediaEmbed embed={embed} {...rest} />
|
||||
@@ -87,7 +90,8 @@ function MediaEmbed({
|
||||
embed: TEmbed
|
||||
}) {
|
||||
switch (embed.type) {
|
||||
case 'images': {
|
||||
case 'images':
|
||||
case 'gallery': {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
@@ -110,6 +114,21 @@ function MediaEmbed({
|
||||
</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 (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function FindContactsBannerNUX() {
|
||||
a.self_end,
|
||||
a.mt_sm,
|
||||
]}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
|
||||
<Text
|
||||
|
||||
@@ -27,6 +27,7 @@ export function ContactsHeroImage() {
|
||||
alt={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() {
|
||||
alt={_(
|
||||
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</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.',
|
||||
}),
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</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.',
|
||||
}),
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
<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={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() {
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() {
|
||||
alt={_(
|
||||
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</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.',
|
||||
}),
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {Card} from './Card'
|
||||
export {
|
||||
type ChatInviteAction,
|
||||
type ChatInviteContextValue,
|
||||
useChatInvite,
|
||||
} from './Context'
|
||||
export {JoinButton} from './JoinButton'
|
||||
export {Root} from './Root'
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
@@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
|
||||
import {groupReactions} from './ReactionsDialog'
|
||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||
|
||||
@@ -185,8 +187,10 @@ let MessageItem = ({
|
||||
|
||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
const hasEmbed =
|
||||
AppBskyEmbedRecord.isView(message.embed) ||
|
||||
ChatBskyEmbedJoinLink.isView(message.embed)
|
||||
const hasEmbedAndText = hasEmbed && rt.text.length > 0
|
||||
|
||||
const targetBottomRadius = squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
@@ -427,6 +431,15 @@ let MessageItem = ({
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{ChatBskyEmbedJoinLink.isView(message.embed) && (
|
||||
<MessageItemInviteEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<Animated.View
|
||||
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}
|
||||
@@ -142,6 +142,7 @@ export function AutoSizedImage({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
|
||||
@@ -343,6 +343,11 @@ export function Gallery({
|
||||
marginLeft: -insetLeft,
|
||||
width,
|
||||
},
|
||||
// Prevent horizontal trackpad/wheel swipes from triggering the
|
||||
// browser's back/forward overscroll-navigation gesture. Handles
|
||||
// Chrome and Firefox; Safari is handled via the wheel listener in
|
||||
// usePointerHandlers.web.ts since it ignores overscroll-behavior.
|
||||
web({overscrollBehaviorX: 'contain'}),
|
||||
]}
|
||||
contentContainerStyle={{
|
||||
gap: ITEM_GAP,
|
||||
@@ -483,8 +488,38 @@ function GalleryImage({
|
||||
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 ? (
|
||||
<View
|
||||
accessible={false}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyFeedDefs,
|
||||
@@ -27,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
||||
},
|
||||
) {
|
||||
// don't ever check gates like this, except this one time
|
||||
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
|
||||
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
@@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
return
|
||||
}
|
||||
|
||||
// The gate only controls whether legacy image embeds opt into the new
|
||||
// expanded gallery layout. Gallery embeds always render expanded by item
|
||||
// count, so their offset must apply regardless of the gate.
|
||||
const isPostGalleryEmbedEnabled = features.isOn(
|
||||
Features.PostGalleryEmbedEnable,
|
||||
)
|
||||
|
||||
/*
|
||||
* First check if we even have images
|
||||
*/
|
||||
@@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
embed,
|
||||
AppBskyEmbedImages.isMain,
|
||||
)
|
||||
const isGalleryEmbed =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
|
||||
embed,
|
||||
AppBskyEmbedGallery.isMain,
|
||||
)
|
||||
const isRecordWithMedia =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
|
||||
@@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
)
|
||||
let hasImages = false
|
||||
if (isImageEmbed) {
|
||||
if (!isPostGalleryEmbedEnabled) return
|
||||
// one image, not a gallery
|
||||
if (embed.images.length === 1) return
|
||||
hasImages = true
|
||||
}
|
||||
if (isGalleryEmbed) {
|
||||
// single (or empty) gallery - no offset needed
|
||||
if (embed.items.length <= 1) return
|
||||
hasImages = true
|
||||
}
|
||||
if (isRecordWithMedia) {
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
||||
@@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
AppBskyEmbedImages.isMain,
|
||||
)
|
||||
) {
|
||||
if (!isPostGalleryEmbedEnabled) return
|
||||
// one image, not a gallery
|
||||
if (embed.media.images.length === 1) return
|
||||
}
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
|
||||
embed.media,
|
||||
AppBskyEmbedGallery.isMain,
|
||||
)
|
||||
) {
|
||||
// single (or empty) gallery - no offset needed
|
||||
if (embed.media.items.length <= 1) return
|
||||
}
|
||||
hasImages = true
|
||||
}
|
||||
if (!hasImages) return
|
||||
|
||||
@@ -4,6 +4,7 @@ import {type FlatList} from 'react-native'
|
||||
import {ITEM_GAP} from '#/components/images/Gallery/const'
|
||||
import {tween} from '#/components/images/Gallery/tween'
|
||||
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
|
||||
import {IS_WEB_SAFARI} from '#/env'
|
||||
|
||||
const DRAG_THRESHOLD = 3
|
||||
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('wheel', onWheel, {passive: false})
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
if (stopTween) stopTween()
|
||||
|
||||
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
|
||||
onPressIn?: (index: number) => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
}
|
||||
|
||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
||||
export function ImageLayoutGrid({
|
||||
style,
|
||||
isWithinQuote: isWithinQuoteProp,
|
||||
...props
|
||||
}: ImageLayoutGridProps) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const gap =
|
||||
const isWithinQuote =
|
||||
isWithinQuoteProp ??
|
||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? gtMobile
|
||||
? a.gap_xs
|
||||
: a.gap_2xs
|
||||
: a.gap_xs
|
||||
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
|
||||
<ImageLayoutGridInner {...props} gap={gap} />
|
||||
<ImageLayoutGridInner
|
||||
{...props}
|
||||
gap={gap}
|
||||
isWithinQuote={isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
gap: {gap: number}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Props {
|
||||
onPressIn?: EventFunction
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
insetBorderStyle?: StyleProp<ViewStyle>
|
||||
containerRefs: AnimatedRef<any>[]
|
||||
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
||||
@@ -42,6 +43,7 @@ export function GalleryItem({
|
||||
onPressIn,
|
||||
onLongPress,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
@@ -52,6 +54,7 @@ export function GalleryItem({
|
||||
const image = images[index]
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
isWithinQuote ??
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
|
||||
const aspect =
|
||||
@@ -106,6 +109,7 @@ export function GalleryItem({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
|
||||
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
||||
codes: code ? [code] : undefined,
|
||||
hasSession,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
||||
@@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
) {
|
||||
errorMessage = l`The member limit has been reached.`
|
||||
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
|
||||
errorMessage = l`You have been removed from this group.`
|
||||
errorMessage = l`You have been previously removed from this group and can’t join it using this link.`
|
||||
}
|
||||
Toast.show(errorMessage)
|
||||
},
|
||||
@@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
<View
|
||||
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.mb_2xs,
|
||||
a.text_center,
|
||||
@@ -402,7 +404,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
color="primary"
|
||||
disabled={!code}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>Open chat</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Open chat</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ArrowRightIcon} />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
}
|
||||
accessibilityHint={
|
||||
joinLinkPreview.requireApproval
|
||||
? l`Request access to join this group chat`
|
||||
: l`Join this group chat`
|
||||
? l`Tap to request access to join this group chat`
|
||||
: l`Tap to join this group chat immediately`
|
||||
}
|
||||
size="large"
|
||||
color={buttonColor}
|
||||
|
||||
@@ -87,7 +87,10 @@ export function parseReportSubject(
|
||||
reply: !!record.reply,
|
||||
image:
|
||||
embed.type === 'images' ||
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
|
||||
embed.type === 'gallery' ||
|
||||
(embed.type === 'post_with_media' &&
|
||||
(embed.media.type === 'images' ||
|
||||
embed.media.type === 'gallery')),
|
||||
video:
|
||||
embed.type === 'video' ||
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
||||
|
||||
@@ -87,6 +87,7 @@ function Inner({
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -54,6 +54,7 @@ export function LinkPreview({
|
||||
contentFit="cover"
|
||||
onLoad={() => setImageLoadError(false)}
|
||||
onError={() => setImageLoadError(true)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
)}
|
||||
{linkMeta && (!linkMeta.image || imageLoadError) && (
|
||||
|
||||
@@ -147,6 +147,7 @@ export function LiveStatus({
|
||||
contentFit="cover"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<LiveIndicator
|
||||
size="large"
|
||||
|
||||
+52
-3
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedExternal,
|
||||
type AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyEmbedRecord,
|
||||
type AppBskyEmbedRecordWithMedia,
|
||||
@@ -21,6 +22,7 @@ import {sha256} from 'js-sha256'
|
||||
import {CID} from 'multiformats/cid'
|
||||
import * as Hasher from 'multiformats/hashes/hasher'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
@@ -177,7 +179,8 @@ export async function post(
|
||||
writes: writes,
|
||||
validate: true,
|
||||
})
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.error(`Failed to create post`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
@@ -252,6 +255,7 @@ async function resolveEmbed(
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
@@ -311,6 +315,7 @@ async function resolveMedia(
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| undefined
|
||||
> {
|
||||
@@ -323,7 +328,10 @@ async function resolveMedia(
|
||||
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
const {path, width, height, mime} = await compressImage(
|
||||
image,
|
||||
IMAGE_SIZE_CONFIG_POSTS,
|
||||
)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
@@ -338,6 +346,34 @@ async function resolveMedia(
|
||||
images,
|
||||
}
|
||||
}
|
||||
if (embedDraft.media?.type === 'gallery') {
|
||||
const imagesDraft = embedDraft.media.images
|
||||
logger.debug(`Uploading images`, {
|
||||
count: imagesDraft.length,
|
||||
})
|
||||
onStateChange?.(t`Uploading images...`)
|
||||
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(
|
||||
image,
|
||||
IMAGE_SIZE_CONFIG_POSTS,
|
||||
)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
$type: 'app.bsky.embed.gallery#image' as const,
|
||||
image: res.data.blob,
|
||||
alt: image.alt,
|
||||
aspectRatio: {width, height},
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
$type: 'app.bsky.embed.gallery',
|
||||
items,
|
||||
}
|
||||
}
|
||||
if (
|
||||
embedDraft.media?.type === 'video' &&
|
||||
embedDraft.media.video.status === 'done'
|
||||
@@ -427,6 +463,16 @@ async function resolveMedia(
|
||||
},
|
||||
}
|
||||
}
|
||||
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
|
||||
return {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: resolvedLink.uri,
|
||||
title: resolvedLink.view.name,
|
||||
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -470,6 +516,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
|
||||
}
|
||||
|
||||
// Returns a transformed version of the object for use in DAG-CBOR.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function prepareForHashing(v: any): any {
|
||||
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
|
||||
// the API client will convert this for you but we're hashing in the client,
|
||||
@@ -492,9 +539,10 @@ function prepareForHashing(v: any): any {
|
||||
|
||||
// Walk through plain objects
|
||||
if (isPlainObject(v)) {
|
||||
const obj: any = {}
|
||||
const obj: Record<string, unknown> = {}
|
||||
let pure = true
|
||||
for (const key in v) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
let value = v[key]
|
||||
// `value` is undefined
|
||||
if (value === undefined) {
|
||||
@@ -513,6 +561,7 @@ function prepareForHashing(v: any): any {
|
||||
return v
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isPlainObject(v: any): boolean {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false
|
||||
|
||||
+25
-5
@@ -2,11 +2,12 @@ import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
type BskyAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
|
||||
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
|
||||
import {downloadAndResize} from '#/lib/media/manip'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyCustomFeedUrl,
|
||||
isBskyListUrl,
|
||||
isBskyPostUrl,
|
||||
@@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = {
|
||||
view: AppBskyGraphDefs.StarterPackView
|
||||
}
|
||||
|
||||
type ResolvedChatInvite = {
|
||||
type: 'chat-invite'
|
||||
uri: string
|
||||
code: string
|
||||
view?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
}
|
||||
|
||||
export type ResolvedLink =
|
||||
| ResolvedExternalLink
|
||||
| ResolvedPostRecord
|
||||
| ResolvedFeedRecord
|
||||
| ResolvedListRecord
|
||||
| ResolvedStarterPackRecord
|
||||
| ResolvedChatInvite
|
||||
|
||||
export class EmbeddingDisabledError extends Error {
|
||||
constructor() {
|
||||
@@ -141,6 +151,19 @@ export async function resolveLink(
|
||||
view: res.data.list,
|
||||
}
|
||||
}
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(uri)
|
||||
if (chatInviteCode) {
|
||||
const res = await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes: [chatInviteCode]},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
return {
|
||||
type: 'chat-invite',
|
||||
uri,
|
||||
code: chatInviteCode,
|
||||
view: res.data.joinLinkPreviews[0],
|
||||
}
|
||||
}
|
||||
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
|
||||
const parsed = parseStarterPackUri(uri)
|
||||
if (!parsed) {
|
||||
@@ -261,10 +284,7 @@ export async function imageToThumb(
|
||||
try {
|
||||
const img = await downloadAndResize({
|
||||
uri: imageUri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
...IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
timeout: 15e3,
|
||||
})
|
||||
if (img) {
|
||||
|
||||
@@ -97,10 +97,14 @@ export const STAGING_FEEDS = [
|
||||
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
|
||||
]
|
||||
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
size: 1000000,
|
||||
export const IMAGE_SIZE_CONFIG_POSTS = {
|
||||
maxDimension: 4000,
|
||||
maxSize: 2000000,
|
||||
}
|
||||
|
||||
export const IMAGE_SIZE_CONFIG_2K_1MB = {
|
||||
maxDimension: 2000,
|
||||
maxSize: 1000000,
|
||||
}
|
||||
|
||||
export const STAGING_LINK_META_PROXY =
|
||||
|
||||
+16
-39
@@ -16,24 +16,21 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type Dimensions} from './types'
|
||||
import {convertCdnPreset} from './util'
|
||||
import {convertCdnPreset, getResizedDimensions} from './util'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: PickerImage,
|
||||
maxSize: number = POST_IMG_MAX.size,
|
||||
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
||||
): Promise<PickerImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
const resizedImage = await doResize(normalizePath(img.path), {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxDimension,
|
||||
maxSize,
|
||||
})
|
||||
const finalImageMovedPath = await moveToPermanentPath(
|
||||
@@ -49,9 +46,7 @@ export async function compressIfNeeded(
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxDimension: number
|
||||
maxSize: number
|
||||
timeout: number
|
||||
}
|
||||
@@ -67,7 +62,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)
|
||||
|
||||
try {
|
||||
return await doResize(path, opts)
|
||||
return await doResize(path, {
|
||||
maxDimension: opts.maxDimension,
|
||||
maxSize: opts.maxSize,
|
||||
})
|
||||
} finally {
|
||||
void safeDeleteAsync(path)
|
||||
}
|
||||
@@ -188,9 +186,7 @@ export function getImageDim(path: string): Promise<Dimensions> {
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxDimension: number
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
@@ -204,10 +200,13 @@ async function doResize(
|
||||
// Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
|
||||
// does not work for local files...
|
||||
const imageRes = await manipulateAsync(localUri, [], {})
|
||||
const newDimensions = getResizedDimensions({
|
||||
width: imageRes.width,
|
||||
height: imageRes.height,
|
||||
})
|
||||
const newDimensions = getResizedDimensions(
|
||||
{
|
||||
width: imageRes.width,
|
||||
height: imageRes.height,
|
||||
},
|
||||
opts.maxDimension,
|
||||
)
|
||||
|
||||
let minQualityPercentage = 0
|
||||
let maxQualityPercentage = 101 // exclusive
|
||||
@@ -388,28 +387,6 @@ async function withTempFile<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) {
|
||||
// Download to a temp path first, then rename with the correct extension
|
||||
// based on the response's mimeType.
|
||||
|
||||
+22
-17
@@ -1,27 +1,28 @@
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type Dimensions} from './types'
|
||||
import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util'
|
||||
import {
|
||||
blobToDataUri,
|
||||
convertCdnPreset,
|
||||
getDataUriSize,
|
||||
getResizedDimensions,
|
||||
} from './util'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: PickerImage,
|
||||
maxSize: number,
|
||||
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
||||
): Promise<PickerImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
return await doResize(img.path, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxDimension,
|
||||
maxSize,
|
||||
})
|
||||
}
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxDimension: number
|
||||
maxSize: number
|
||||
timeout: number
|
||||
}
|
||||
@@ -34,7 +35,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
clearTimeout(to)
|
||||
|
||||
const dataUri = await blobToDataUri(resBody)
|
||||
return await doResize(dataUri, opts)
|
||||
return await doResize(dataUri, {
|
||||
maxDimension: opts.maxDimension,
|
||||
maxSize: opts.maxSize,
|
||||
})
|
||||
}
|
||||
|
||||
export async function shareImageModal(_opts: {uri: string}) {
|
||||
@@ -70,9 +74,7 @@ export async function getImageDim(path: string): Promise<Dimensions> {
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxDimension: number
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
@@ -80,6 +82,9 @@ async function doResize(
|
||||
dataUri: string,
|
||||
opts: DoResizeOpts,
|
||||
): Promise<PickerImage> {
|
||||
const sourceDims = await getImageDim(dataUri)
|
||||
const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension)
|
||||
|
||||
let newDataUri
|
||||
|
||||
let minQualityPercentage = 0
|
||||
@@ -90,10 +95,10 @@ async function doResize(
|
||||
(maxQualityPercentage + minQualityPercentage) / 2,
|
||||
)
|
||||
const tempDataUri = await createResizedImage(dataUri, {
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
width: newDimensions.width,
|
||||
height: newDimensions.height,
|
||||
quality: qualityPercentage / 100,
|
||||
mode: opts.mode,
|
||||
mode: 'contain',
|
||||
})
|
||||
|
||||
if (getDataUriSize(tempDataUri) < opts.maxSize) {
|
||||
@@ -111,8 +116,8 @@ async function doResize(
|
||||
path: newDataUri,
|
||||
mime: 'image/jpeg',
|
||||
size: getDataUriSize(newDataUri),
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
width: newDimensions.width,
|
||||
height: newDimensions.height,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ExpoImageCropTool, {
|
||||
type OpenCropperOptions,
|
||||
} from '@bsky.app/expo-image-crop-tool'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {compressIfNeeded} from './manip'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
|
||||
@@ -28,13 +29,16 @@ async function getFile() {
|
||||
throw new Error('Failed to get file info')
|
||||
}
|
||||
|
||||
return await compressIfNeeded({
|
||||
path: file,
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
})
|
||||
return await compressIfNeeded(
|
||||
{
|
||||
path: file,
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
},
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPicker(): Promise<PickerImage[]> {
|
||||
|
||||
@@ -2,6 +2,31 @@ export function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
||||
|
||||
export function getResizedDimensions(
|
||||
originalDims: {
|
||||
width: number
|
||||
height: number
|
||||
},
|
||||
maxDimension: number,
|
||||
) {
|
||||
if (
|
||||
originalDims.width <= maxDimension &&
|
||||
originalDims.height <= maxDimension
|
||||
) {
|
||||
return originalDims
|
||||
}
|
||||
|
||||
const ratio = Math.min(
|
||||
maxDimension / originalDims.width,
|
||||
maxDimension / originalDims.height,
|
||||
)
|
||||
|
||||
return {
|
||||
width: Math.round(originalDims.width * ratio),
|
||||
height: Math.round(originalDims.height * ratio),
|
||||
}
|
||||
}
|
||||
|
||||
// Fairly accurate estimate that is more performant
|
||||
// than decoding and checking length of URI
|
||||
export function getDataUriSize(uri: string): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import psl from 'psl'
|
||||
import {parse} from 'psl'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
import {BSKY_SERVICE} from '#/lib/constants'
|
||||
@@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
|
||||
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
||||
|
||||
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
||||
@@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean {
|
||||
}
|
||||
|
||||
export function splitApexDomain(hostname: string): [string, string] {
|
||||
const hostnamep = psl.parse(hostname)
|
||||
const hostnamep = parse(hostname)
|
||||
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
||||
return ['', hostname]
|
||||
}
|
||||
|
||||
+212
-212
File diff suppressed because it is too large
Load Diff
@@ -411,7 +411,6 @@ function Header({
|
||||
count?: number
|
||||
hasMoreRequests?: boolean
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
@@ -420,15 +419,15 @@ function Header({
|
||||
{count === undefined ? (
|
||||
<Trans>Requests to join</Trans>
|
||||
) : hasMoreRequests ? (
|
||||
l({
|
||||
message: `${count}+ requests to join`,
|
||||
comment:
|
||||
'Displayed when there are more requests to join a group chat than have been loaded',
|
||||
})
|
||||
<Plural
|
||||
value={count}
|
||||
other="#+ requests to join"
|
||||
comment="Displayed when there are more requests to join a group chat than have been loaded"
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={count}
|
||||
zero="No requests to join"
|
||||
_0="No requests to join"
|
||||
one="# request to join"
|
||||
other="# requests to join"
|
||||
/>
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -178,11 +177,7 @@ export function InviteLinkDialog({
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Group chats can only have a maximum of{' '}
|
||||
{plural(convo.details.memberLimit, {
|
||||
one: '# person',
|
||||
other: '# people',
|
||||
})}
|
||||
.
|
||||
<Plural value={convo.details.memberLimit} other="# people" />.
|
||||
</Trans>
|
||||
</Text>
|
||||
<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 {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {useEmail} from '#/state/email-verification'
|
||||
import {
|
||||
useMessageDraft,
|
||||
@@ -233,7 +233,11 @@ export function MessageComposer({
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onFacetCommitted={facet => {
|
||||
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||
if (
|
||||
facet.type === 'url' &&
|
||||
(isBskyPostUrl(facet.value) ||
|
||||
isBskyChatInviteUrl(facet.value))
|
||||
) {
|
||||
setEmbed(facet.value)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyChatInviteUrl,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
@@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* The embed staged in the message composer. A message can carry at most one
|
||||
* embed: either a quoted post or a group chat invite link.
|
||||
*/
|
||||
export type MessageEmbedState =
|
||||
| {type: 'post'; uri: string}
|
||||
| {type: 'invite'; code: string}
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
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) {
|
||||
setEmbedUri(embedFromParams)
|
||||
if (embedFromParams && embed?.type !== 'post') {
|
||||
setEmbedState({type: 'post', uri: embedFromParams})
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
// Only the post embed is reflected in the route param (used by the
|
||||
// share-to-DM intent flow); invites are local-only.
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbedUri(undefined)
|
||||
setEmbedState(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
if (isBskyChatInviteUrl(embedUrl)) {
|
||||
const code = getChatInviteCodeFromUrl(embedUrl)
|
||||
if (code) {
|
||||
setEmbedState({type: 'invite', code})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setEmbedUri(uri)
|
||||
if (isBskyPostUrl(embedUrl)) {
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
setEmbedState({type: 'post', uri})
|
||||
}
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
@@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets(
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
if (
|
||||
AppBskyRichtextFacet.isLink(feature) &&
|
||||
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
|
||||
) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
@@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets(
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
embed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const onRemove = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}, [setEmbed])
|
||||
|
||||
if (!embed) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case 'post':
|
||||
return <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: l} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
const {data: post, status} = usePostQuery(uri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
@@ -134,15 +185,6 @@ export function MessageInputEmbed({
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending': {
|
||||
return (
|
||||
@@ -220,6 +262,71 @@ export function MessageInputEmbed({
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbed({
|
||||
code,
|
||||
onRemove,
|
||||
}: {
|
||||
code: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<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({
|
||||
children,
|
||||
onRemove,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||
@@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
@@ -46,9 +48,10 @@ import {
|
||||
useConvoActive,
|
||||
} from '#/state/messages/convo'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
@@ -131,8 +134,10 @@ export function MessagesList({
|
||||
const ax = useAnalytics()
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
const getJoinLinkPreview = useGetJoinLinkPreview()
|
||||
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
|
||||
const t = useTheme()
|
||||
|
||||
const textInputId = 'chat-input-' + useId()
|
||||
@@ -348,12 +353,38 @@ export function MessagesList({
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
|
||||
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined
|
||||
let embed:
|
||||
| $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 {
|
||||
const post = await getPost({uri: embedUri})
|
||||
const post = await getPost({uri: messageEmbed.uri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
@@ -368,42 +399,34 @@ export function MessagesList({
|
||||
record: createEmbedViewRecordFromPost(post),
|
||||
}
|
||||
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
stripLinkFacet(uri => {
|
||||
if (!isBskyPostUrl(uri)) return false
|
||||
const url = convertBskyAppUrlIfNeeded(uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
} else if (messageEmbed?.type === 'invite') {
|
||||
const code = messageEmbed.code
|
||||
embed = {
|
||||
$type: 'chat.bsky.embed.joinLink',
|
||||
code,
|
||||
}
|
||||
|
||||
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
|
||||
if (joinLinkPreview) {
|
||||
embedView = {
|
||||
$type: 'chat.bsky.embed.joinLink#view',
|
||||
joinLinkPreview,
|
||||
}
|
||||
}
|
||||
|
||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
@@ -424,7 +447,16 @@ export function MessagesList({
|
||||
embedView,
|
||||
)
|
||||
},
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
[
|
||||
agent,
|
||||
convoState,
|
||||
messageEmbed,
|
||||
getPost,
|
||||
getJoinLinkPreview,
|
||||
hasSession,
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
],
|
||||
)
|
||||
|
||||
const scrollToEndOnPress = useCallback(() => {
|
||||
@@ -595,11 +627,11 @@ export function MessagesList({
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
@@ -607,11 +639,11 @@ export function MessagesList({
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
|
||||
@@ -83,6 +83,7 @@ function Page({
|
||||
style={[a.w_full, a.aspect_square]}
|
||||
alt={alt}
|
||||
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
{page === 1 && (
|
||||
<Image
|
||||
@@ -97,6 +98,7 @@ function Page({
|
||||
},
|
||||
]}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
alt={_(msg`Your profile picture`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
@@ -212,7 +213,7 @@ export function StepProfile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
image = await compressIfNeeded(image, 1000000)
|
||||
image = await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB)
|
||||
|
||||
// If we are on mobile, prefetching the image will load the image into memory before we try and display it,
|
||||
// stopping any brief flickers.
|
||||
|
||||
@@ -105,6 +105,7 @@ function GermLogo({size}: {size: 'small' | 'large'}) {
|
||||
source={require('../../../../assets/images/germ_logo.webp')}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
contentFit="cover"
|
||||
useAppleWebpCodec
|
||||
style={[
|
||||
a.rounded_full,
|
||||
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {useState} from 'react'
|
||||
import {Alert, View} from 'react-native'
|
||||
import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type ImageSourcePropType} from 'react-native'
|
||||
import type * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
|
||||
import type * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
|
||||
|
||||
export type AppIconSet = {
|
||||
id: DynamicAppIcon.IconName
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
|
||||
import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets'
|
||||
|
||||
+16
-6
@@ -201,12 +201,17 @@ export function resetImageManipulation(
|
||||
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
|
||||
|
||||
let attempts = 0
|
||||
let maxDimension = 4000
|
||||
let maxBytes = 2000000
|
||||
// Seeded from `maxDimension` but shrunk per attempt below, so keep the
|
||||
// passed-in value pristine.
|
||||
let currentDimension = maxDimension
|
||||
const maxBytes = maxSize
|
||||
|
||||
let minQualityPercentage = 0
|
||||
let maxQualityPercentage = 101 // exclusive
|
||||
@@ -215,7 +220,11 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||
while (maxQualityPercentage - minQualityPercentage > 1) {
|
||||
if (attempts >= 4) break
|
||||
|
||||
const [w, h] = containImageRes(source.width, source.height, maxDimension)
|
||||
const [w, h] = containImageRes(
|
||||
source.width,
|
||||
source.height,
|
||||
currentDimension,
|
||||
)
|
||||
const qualityPercentage = Math.round(
|
||||
(maxQualityPercentage + minQualityPercentage) / 2,
|
||||
)
|
||||
@@ -230,8 +239,9 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||
minQualityPercentage = 0
|
||||
maxQualityPercentage = 101
|
||||
attempts++
|
||||
// 4000px → 3200px → 2560px → 2048px → ~1638px
|
||||
maxDimension = Math.floor(maxDimension * 0.8)
|
||||
// max.width → 0.8× → 0.64× → 0.512× → ~0.41×
|
||||
// e.g. 4000px → 3200px → 2560px → 2048px → ~1638px
|
||||
currentDimension = Math.floor(currentDimension * 0.8)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyConvoGetLog,
|
||||
type ChatBskyConvoSendMessage,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
type ChatBskyGroupDefs,
|
||||
} from '@atproto/api'
|
||||
import {XRPCError} from '@atproto/api'
|
||||
@@ -109,7 +110,9 @@ export class Convo {
|
||||
{
|
||||
id: string
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message']
|
||||
optimisticEmbedView?: $Typed<AppBskyEmbedRecord.View>
|
||||
optimisticEmbedView?:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
}
|
||||
> = new Map()
|
||||
private deletedMessages: Set<string> = new Set()
|
||||
@@ -942,7 +945,9 @@ export class Convo {
|
||||
|
||||
sendMessage(
|
||||
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
|
||||
if (!message.text.trim() && !message.embed) return
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ChatBskyActorDefs,
|
||||
type ChatBskyConvoDefs,
|
||||
type ChatBskyConvoSendMessage,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||
@@ -108,7 +109,10 @@ export type ConvoItem =
|
||||
type DeleteMessage = (messageId: string) => Promise<void>
|
||||
type SendMessage = (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
optimisticEmbedView: $Typed<AppBskyEmbedRecord.View> | undefined,
|
||||
optimisticEmbedView:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined,
|
||||
) => void
|
||||
type FetchMessageHistory = () => Promise<void>
|
||||
type MarkConvoAccepted = () => void
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {useCallback} from 'react'
|
||||
import {
|
||||
AtpAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
type ChatBskyGroupGetJoinLinkPreviews,
|
||||
} from '@atproto/api'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
@@ -17,12 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: {
|
||||
persistedVersion: 1,
|
||||
})
|
||||
|
||||
export function useJoinLinkPreviewsQuery({
|
||||
async function fetchJoinLinkPreviews({
|
||||
agent,
|
||||
codes,
|
||||
hasSession,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
codes: string[]
|
||||
hasSession: boolean
|
||||
}) {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function useJoinLinkPreviewsQuery({
|
||||
codes,
|
||||
hasSession,
|
||||
staleTime = STALE.MINUTES.ONE,
|
||||
initialData,
|
||||
}: {
|
||||
codes?: string[]
|
||||
hasSession: boolean
|
||||
staleTime?: number
|
||||
/**
|
||||
* Seed the query with an already-known preview (e.g. a DM message embed
|
||||
* already carries the resolved view), avoiding a duplicate fetch.
|
||||
*/
|
||||
initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -31,21 +63,15 @@ export function useJoinLinkPreviewsQuery({
|
||||
queryFn: async () => {
|
||||
if (!codes) throw new Error('No invite code')
|
||||
try {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
return await fetchJoinLinkPreviews({agent, codes, hasSession})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch join link preview', {safeMessage: error})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
enabled: codes != null && codes.length > 0,
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
staleTime,
|
||||
initialData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() {
|
||||
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
|
||||
queryFn: async () => {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,14 +39,16 @@ export function useJoinRequestMutation<A extends JoinRequestAction>(
|
||||
return useMutation({
|
||||
mutationFn: async ({member}: {member: string}) => {
|
||||
if (!convoId) throw new Error('No convoId provided')
|
||||
const endpoint =
|
||||
const {data} =
|
||||
action === 'approve'
|
||||
? agent.chat.bsky.group.approveJoinRequest
|
||||
: agent.chat.bsky.group.rejectJoinRequest
|
||||
const {data} = await endpoint(
|
||||
{convoId, member},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
? await agent.chat.bsky.group.approveJoinRequest(
|
||||
{convoId, member},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
: await agent.chat.bsky.group.rejectJoinRequest(
|
||||
{convoId, member},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
return data as JoinRequestOutput<A>
|
||||
},
|
||||
onMutate: ({member}) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {device} from '#/storage'
|
||||
|
||||
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil
|
||||
@@ -77,8 +76,6 @@ export function configureAdditionalModerationAuthorities() {
|
||||
if (geolocation?.countryCode) {
|
||||
// overwrite with only those necessary
|
||||
additionalLabelers = MODERATION_AUTHORITIES[geolocation.countryCode] ?? []
|
||||
} else {
|
||||
logger.info(`no geolocation, cannot apply mod authorities`)
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
@@ -89,10 +86,5 @@ export function configureAdditionalModerationAuthorities() {
|
||||
new Set([...BskyAgent.appLabelers, ...additionalLabelers]),
|
||||
)
|
||||
|
||||
logger.info(`applying mod authorities`, {
|
||||
additionalLabelers,
|
||||
appLabelers,
|
||||
})
|
||||
|
||||
BskyAgent.configure({appLabelers})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type $Typed,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -47,6 +48,10 @@ export type Embed =
|
||||
type: 'images'
|
||||
view: $Typed<AppBskyEmbedImages.View>
|
||||
}
|
||||
| {
|
||||
type: 'gallery'
|
||||
view: $Typed<AppBskyEmbedGallery.View>
|
||||
}
|
||||
| {
|
||||
type: 'link'
|
||||
view: $Typed<AppBskyEmbedExternal.View>
|
||||
@@ -122,6 +127,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
|
||||
type: 'images',
|
||||
view: embed,
|
||||
}
|
||||
} else if (AppBskyEmbedGallery.isView(embed)) {
|
||||
return {
|
||||
type: 'gallery',
|
||||
view: embed,
|
||||
}
|
||||
} else if (AppBskyEmbedExternal.isView(embed)) {
|
||||
return {
|
||||
type: 'link',
|
||||
|
||||
@@ -159,7 +159,7 @@ import {
|
||||
composerReducer,
|
||||
createComposerState,
|
||||
type EmbedDraft,
|
||||
MAX_IMAGES,
|
||||
MAX_GALLERY_IMAGES,
|
||||
type PostAction,
|
||||
type PostDraft,
|
||||
type ThreadDraft,
|
||||
@@ -178,6 +178,65 @@ type CancelRef = {
|
||||
onPressCancel: () => void
|
||||
}
|
||||
|
||||
function applyGalleryCap(
|
||||
currentCount: number,
|
||||
incoming: ComposerImage[],
|
||||
):
|
||||
| {status: 'full'}
|
||||
| {status: 'partial'; accepted: ComposerImage[]; dropped: number}
|
||||
| {status: 'ok'; accepted: ComposerImage[]} {
|
||||
const remaining = MAX_GALLERY_IMAGES - currentCount
|
||||
if (remaining <= 0) {
|
||||
return {status: 'full'}
|
||||
}
|
||||
if (incoming.length > remaining) {
|
||||
return {
|
||||
status: 'partial',
|
||||
accepted: incoming.slice(0, remaining),
|
||||
dropped: incoming.length - remaining,
|
||||
}
|
||||
}
|
||||
return {status: 'ok', accepted: incoming}
|
||||
}
|
||||
|
||||
function useAddImagesWithCap(
|
||||
currentCount: number,
|
||||
dispatchPostAction: (action: PostAction) => void,
|
||||
) {
|
||||
const {t: l} = useLingui()
|
||||
return useCallback(
|
||||
(next: ComposerImage[]) => {
|
||||
const result = applyGalleryCap(currentCount, next)
|
||||
if (result.status === 'full') {
|
||||
Toast.show(
|
||||
l({
|
||||
message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`,
|
||||
comment:
|
||||
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
|
||||
}),
|
||||
{type: 'warning'},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result.status === 'partial') {
|
||||
Toast.show(
|
||||
l({
|
||||
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
|
||||
comment:
|
||||
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
|
||||
}),
|
||||
{type: 'warning'},
|
||||
)
|
||||
}
|
||||
dispatchPostAction({
|
||||
type: 'embed_add_images',
|
||||
images: result.accepted,
|
||||
})
|
||||
},
|
||||
[currentCount, dispatchPostAction, l],
|
||||
)
|
||||
}
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = ({
|
||||
replyTo,
|
||||
@@ -611,7 +670,11 @@ export const ComposePost = ({
|
||||
ax.metric('draft:save', {
|
||||
isNewDraft,
|
||||
hasText: posts.some(p => p.richtext.text.trim().length > 0),
|
||||
hasImages: posts.some(p => p.embed.media?.type === 'images'),
|
||||
hasImages: posts.some(
|
||||
p =>
|
||||
p.embed.media?.type === 'images' ||
|
||||
p.embed.media?.type === 'gallery',
|
||||
),
|
||||
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
|
||||
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
|
||||
hasQuote: posts.some(p => !!p.embed.quote),
|
||||
@@ -780,7 +843,10 @@ export const ComposePost = ({
|
||||
for (let i = 0; i < thread.posts.length; i++) {
|
||||
const media = thread.posts[i].embed.media
|
||||
if (media) {
|
||||
if (media.type === 'images' && media.images.some(img => !img.alt)) {
|
||||
if (
|
||||
(media.type === 'images' || media.type === 'gallery') &&
|
||||
media.images.some(img => !img.alt)
|
||||
) {
|
||||
return l`One or more images is missing alt text.`
|
||||
}
|
||||
if (media.type === 'gif' && !media.alt) {
|
||||
@@ -931,7 +997,9 @@ export const ComposePost = ({
|
||||
logger.error(e, {
|
||||
message: `Composer: create post failed`,
|
||||
hasImages: filteredThread.posts.some(
|
||||
p => p.embed.media?.type === 'images',
|
||||
p =>
|
||||
p.embed.media?.type === 'images' ||
|
||||
p.embed.media?.type === 'gallery',
|
||||
),
|
||||
})
|
||||
|
||||
@@ -953,7 +1021,8 @@ export const ComposePost = ({
|
||||
for (let post of filteredThread.posts) {
|
||||
ax.metric('post:create', {
|
||||
imageCount:
|
||||
post.embed.media?.type === 'images'
|
||||
post.embed.media?.type === 'images' ||
|
||||
post.embed.media?.type === 'gallery'
|
||||
? post.embed.media.images.length
|
||||
: 0,
|
||||
isReply: index > 0 || !!replyTo,
|
||||
@@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({
|
||||
[dispatch, post.id],
|
||||
)
|
||||
|
||||
const onImageAdd = useCallback(
|
||||
(next: ComposerImage[]) => {
|
||||
dispatchPost({
|
||||
type: 'embed_add_images',
|
||||
images: next,
|
||||
})
|
||||
},
|
||||
[dispatchPost],
|
||||
)
|
||||
const postImagesCount =
|
||||
post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
|
||||
? post.embed.media.images.length
|
||||
: 0
|
||||
const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost)
|
||||
|
||||
const onNewLink = useCallback(
|
||||
(uri: string) => {
|
||||
@@ -1708,7 +1773,7 @@ function ComposerEmbeds({
|
||||
const video = embed.media?.type === 'video' ? embed.media.video : null
|
||||
return (
|
||||
<>
|
||||
{embed.media?.type === 'images' && (
|
||||
{(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
|
||||
<Gallery images={embed.media.images} dispatch={dispatch} />
|
||||
)}
|
||||
|
||||
@@ -1819,7 +1884,11 @@ function ComposerPills({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const media = post.embed.media
|
||||
const hasMedia = media?.type === 'images' || media?.type === 'video'
|
||||
const hasMedia =
|
||||
media?.type === 'images' ||
|
||||
media?.type === 'gallery' ||
|
||||
media?.type === 'gif' ||
|
||||
media?.type === 'video'
|
||||
const hasLink = !!post.embed.link
|
||||
|
||||
// Don't render anything if no pills are going to be displayed
|
||||
@@ -1908,15 +1977,16 @@ function ComposerFooter({
|
||||
>(undefined)
|
||||
|
||||
const media = post.embed.media
|
||||
const images = media?.type === 'images' ? media.images : []
|
||||
const images =
|
||||
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
|
||||
const video = media?.type === 'video' ? media.video : null
|
||||
const isMaxImages = images.length >= MAX_IMAGES
|
||||
const isMaxImages = images.length >= MAX_GALLERY_IMAGES
|
||||
const isMaxVideos = !!video
|
||||
|
||||
let selectedAssetsCount = 0
|
||||
let isMediaSelectionDisabled = false
|
||||
|
||||
if (media?.type === 'images') {
|
||||
if (media?.type === 'images' || media?.type === 'gallery') {
|
||||
isMediaSelectionDisabled = isMaxImages
|
||||
selectedAssetsCount = images.length
|
||||
} else if (media?.type === 'video') {
|
||||
@@ -1926,15 +1996,7 @@ function ComposerFooter({
|
||||
isMediaSelectionDisabled = !!media
|
||||
}
|
||||
|
||||
const onImageAdd = useCallback(
|
||||
(next: ComposerImage[]) => {
|
||||
dispatch({
|
||||
type: 'embed_add_images',
|
||||
images: next,
|
||||
})
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
const onImageAdd = useAddImagesWithCap(images.length, dispatch)
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
@@ -2017,7 +2079,11 @@ function ComposerFooter({
|
||||
autoOpen={openGallery}
|
||||
/>
|
||||
<OpenCameraBtn
|
||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||
disabled={
|
||||
media?.type === 'images' || media?.type === 'gallery'
|
||||
? isMaxImages
|
||||
: !!media
|
||||
}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
const images = useMemo(() => {
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
return embed.images
|
||||
} else if (
|
||||
AppBskyEmbedRecordWithMedia.isView(embed) &&
|
||||
AppBskyEmbedImages.isView(embed.media)
|
||||
) {
|
||||
return embed.media.images
|
||||
} else if (AppBskyEmbedGallery.isView(embed)) {
|
||||
return galleryItemsToImages(embed.items)
|
||||
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
if (AppBskyEmbedImages.isView(embed.media)) {
|
||||
return embed.media.images
|
||||
} else if (AppBskyEmbedGallery.isView(embed.media)) {
|
||||
return galleryItemsToImages(embed.media.items)
|
||||
}
|
||||
}
|
||||
}, [embed])
|
||||
|
||||
@@ -129,6 +133,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
)
|
||||
}
|
||||
|
||||
function galleryItemsToImages(
|
||||
items: AppBskyEmbedGallery.View['items'],
|
||||
): AppBskyEmbedImages.ViewImage[] {
|
||||
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
|
||||
// the existing layout switch valid for galleries up to 10 items.
|
||||
return items
|
||||
.filter(AppBskyEmbedGallery.isViewImage)
|
||||
.slice(0, 4)
|
||||
.map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}))
|
||||
}
|
||||
|
||||
function ComposerReplyToImages({
|
||||
images,
|
||||
}: {
|
||||
@@ -151,8 +171,8 @@ function ComposerReplyToImages({
|
||||
<Image
|
||||
source={{uri: images[0].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
)) ||
|
||||
(images.length === 2 && (
|
||||
@@ -160,14 +180,14 @@ function ComposerReplyToImages({
|
||||
<Image
|
||||
source={{uri: images[0].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<Image
|
||||
source={{uri: images[1].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
)) ||
|
||||
@@ -176,21 +196,21 @@ function ComposerReplyToImages({
|
||||
<Image
|
||||
source={{uri: images[0].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Image
|
||||
source={{uri: images[1].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<Image
|
||||
source={{uri: images[2].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -201,28 +221,28 @@ function ComposerReplyToImages({
|
||||
<Image
|
||||
source={{uri: images[0].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<Image
|
||||
source={{uri: images[1].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.flex_row, a.gap_2xs]}>
|
||||
<Image
|
||||
source={{uri: images[2].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<Image
|
||||
source={{uri: images[3].thumb}}
|
||||
style={[a.flex_1]}
|
||||
cachePolicy="memory-disk"
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
||||
import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
|
||||
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
||||
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
||||
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||
@@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({
|
||||
hideAlt
|
||||
/>
|
||||
)
|
||||
} else if (data.type === 'chat-invite') {
|
||||
return <JoinRequestEmbed code={data.code} preview={data.view} />
|
||||
} else if (data.kind === 'feed') {
|
||||
return (
|
||||
<ModeratedFeedEmbed
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -11,12 +10,12 @@ export function ExternalEmbedRemoveBtn({
|
||||
style,
|
||||
}: {onRemove: () => void} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}>
|
||||
<Button
|
||||
label={_(msg`Remove attachment`)}
|
||||
label={l`Remove attachment`}
|
||||
onPress={onRemove}
|
||||
size="small"
|
||||
variant="solid"
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {openUnifiedPicker} from '#/lib/media/picker'
|
||||
import {extractDataUriMime} from '#/lib/media/util'
|
||||
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
|
||||
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
@@ -393,7 +393,9 @@ export function SelectMediaButton({
|
||||
const t = useTheme()
|
||||
const hasAutoOpened = useRef(false)
|
||||
|
||||
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
|
||||
// Picker uses the gallery cap; the reducer decides which embed variant
|
||||
// to land in based on the final image count.
|
||||
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount
|
||||
|
||||
const processSelectedAssets = useCallback(
|
||||
async (rawAssets: ImagePickerAsset[]) => {
|
||||
@@ -419,10 +421,10 @@ export function SelectMediaButton({
|
||||
),
|
||||
[SelectedAssetError.MaxImages]: _(
|
||||
msg({
|
||||
message: `You can select up to ${plural(MAX_IMAGES, {
|
||||
message: `You can select up to ${plural(MAX_GALLERY_IMAGES, {
|
||||
other: '# images',
|
||||
})} in total.`,
|
||||
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
|
||||
comment: `Error message for maximum number of images that can be selected to add to a post.`,
|
||||
}),
|
||||
),
|
||||
[SelectedAssetError.MaxVideos]: _(
|
||||
@@ -507,10 +509,11 @@ export function SelectMediaButton({
|
||||
)}
|
||||
accessibilityHint={_(
|
||||
msg({
|
||||
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, {
|
||||
other: '# images',
|
||||
})}, or a single video or GIF.`,
|
||||
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
|
||||
message: `Opens device gallery to select up to ${plural(
|
||||
MAX_GALLERY_IMAGES,
|
||||
{other: '# images'},
|
||||
)}, or a single video or GIF.`,
|
||||
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`,
|
||||
}),
|
||||
)}
|
||||
style={a.p_sm}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
||||
*/
|
||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {resolveLink} from '#/lib/api/resolve'
|
||||
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
|
||||
import {
|
||||
type ComposerState,
|
||||
type EmbedDraft,
|
||||
LEGACY_IMAGES_EMBED_MAX,
|
||||
type PostDraft,
|
||||
} from '#/view/com/composer/state/composer'
|
||||
import {type VideoState} from '#/view/com/composer/state/video'
|
||||
@@ -115,6 +116,16 @@ async function postDraftToServerPost(
|
||||
post.embed.media.images,
|
||||
localRefPaths,
|
||||
)
|
||||
} else if (post.embed.media.type === 'gallery') {
|
||||
draftPost.embedGallery = {
|
||||
$type: 'app.bsky.draft.defs#draftEmbedGallery',
|
||||
items: serializeImages(post.embed.media.images, localRefPaths).map(
|
||||
img => ({
|
||||
$type: 'app.bsky.draft.defs#draftEmbedImage' as const,
|
||||
...img,
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else if (post.embed.media.type === 'video') {
|
||||
const video = await serializeVideo(post.embed.media.video, localRefPaths)
|
||||
if (video) {
|
||||
@@ -269,6 +280,59 @@ function serializeGif(gifMedia: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an array of draft image refs back to ComposerImages. Shared by
|
||||
* both the `embedImages` and `embedGallery` paths in draftToComposerPosts.
|
||||
*/
|
||||
async function restoreDraftImages(
|
||||
draftImages: AppBskyDraftDefs.DraftEmbedImage[],
|
||||
loadedMedia: Map<string, string>,
|
||||
): Promise<ComposerImage[]> {
|
||||
const imagePromises = draftImages.map(async img => {
|
||||
const path = loadedMedia.get(img.localRef.path)
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
try {
|
||||
const dims = await getImageDim(path)
|
||||
width = dims.width
|
||||
height = dims.height
|
||||
} catch (e) {
|
||||
logger.warn('Failed to get image dimensions', {
|
||||
path,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('restoring image with localRefPath', {
|
||||
localRefPath: img.localRef.path,
|
||||
loadedPath: path,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt || '',
|
||||
// Preserve the original localRefPath for reuse when saving
|
||||
localRefPath: img.localRef.path,
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
} satisfies ComposerImage
|
||||
})
|
||||
|
||||
return (await Promise.all(imagePromises)).filter(
|
||||
(img): img is NonNullable<typeof img> => img !== null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert server DraftView to DraftSummary for list display.
|
||||
* Also checks which media files exist locally.
|
||||
@@ -314,6 +378,24 @@ export function draftViewToSummary({
|
||||
}
|
||||
}
|
||||
|
||||
// Process gallery
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(item.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
}
|
||||
images.push({
|
||||
localPath: item.localRef.path,
|
||||
altText: item.alt || '',
|
||||
exists,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process videos
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
@@ -431,54 +513,31 @@ export async function draftToComposerPosts(
|
||||
media: undefined,
|
||||
}
|
||||
|
||||
// Restore images
|
||||
// Restore images / gallery. Pick the variant from the restored count so
|
||||
// we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays
|
||||
// legacy `images`, >4 promotes to `gallery`). This keeps restore robust
|
||||
// to drafts whose server slot disagrees with their count - e.g. a draft
|
||||
// saved in `embedImages` with 5 items would otherwise restore as a
|
||||
// broken `images` variant the rest of the composer can't grow.
|
||||
const restoredImages: ComposerImage[] = []
|
||||
if (post.embedImages && post.embedImages.length > 0) {
|
||||
const imagePromises = post.embedImages.map(async img => {
|
||||
const path = loadedMedia.get(img.localRef.path)
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
try {
|
||||
const dims = await getImageDim(path)
|
||||
width = dims.width
|
||||
height = dims.height
|
||||
} catch (e) {
|
||||
logger.warn('Failed to get image dimensions', {
|
||||
path,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('restoring image with localRefPath', {
|
||||
localRefPath: img.localRef.path,
|
||||
loadedPath: path,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt || '',
|
||||
// Preserve the original localRefPath for reuse when saving
|
||||
localRefPath: img.localRef.path,
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
} satisfies ComposerImage
|
||||
})
|
||||
|
||||
const images = (await Promise.all(imagePromises)).filter(
|
||||
(img): img is NonNullable<typeof img> => img !== null,
|
||||
restoredImages.push(
|
||||
...(await restoreDraftImages(post.embedImages, loadedMedia)),
|
||||
)
|
||||
if (images.length > 0) {
|
||||
embed.media = {type: 'images', images}
|
||||
}
|
||||
}
|
||||
if (post.embedGallery && post.embedGallery.items.length > 0) {
|
||||
const galleryImages = post.embedGallery.items.filter(
|
||||
AppBskyDraftDefs.isDraftEmbedImage,
|
||||
)
|
||||
restoredImages.push(
|
||||
...(await restoreDraftImages(galleryImages, loadedMedia)),
|
||||
)
|
||||
}
|
||||
if (restoredImages.length > 0) {
|
||||
embed.media =
|
||||
restoredImages.length <= LEGACY_IMAGES_EMBED_MAX
|
||||
? {type: 'images', images: restoredImages}
|
||||
: {type: 'gallery', images: restoredImages}
|
||||
}
|
||||
|
||||
// Restore GIF from external embed
|
||||
@@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
|
||||
refs.add(img.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
refs.add(item.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
refs.add(vid.localRef.path)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
|
||||
import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load gallery
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(item.localRef.path)
|
||||
loadedMedia.set(item.localRef.path, url)
|
||||
} catch (e) {
|
||||
logger.error('Failed to load draft gallery image', {
|
||||
path: item.localRef.path,
|
||||
safeMessage: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load videos
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
@@ -226,6 +241,12 @@ export function useDeleteDraftMutation() {
|
||||
await storage.deleteMediaFromLocal(img.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
await storage.deleteMediaFromLocal(item.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
await storage.deleteMediaFromLocal(vid.localRef.path)
|
||||
|
||||
@@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
||||
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
|
||||
// n > 4 wraps to multiple rows via flexWrap on the gallery container.
|
||||
const columns = Math.min(images.length, 4)
|
||||
const side =
|
||||
images.length === 1
|
||||
? 250
|
||||
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
|
||||
images.length
|
||||
: (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns
|
||||
|
||||
const isOverflow = isMobile && images.length > 2
|
||||
|
||||
@@ -245,6 +247,7 @@ const GalleryItem = ({
|
||||
}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
enforceEarlyResizing
|
||||
cachePolicy="none"
|
||||
autoplay={false}
|
||||
contentFit="cover"
|
||||
@@ -272,6 +275,7 @@ const styles = StyleSheet.create({
|
||||
gallery: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: IMAGE_GAP,
|
||||
marginTop: 16,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as MediaLibrary from 'expo-media-library'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera} from '#/lib/media/picker'
|
||||
import {logger} from '#/logger'
|
||||
@@ -35,7 +34,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
||||
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
||||
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
||||
@@ -38,6 +39,11 @@ type ImagesMedia = {
|
||||
images: ComposerImage[]
|
||||
}
|
||||
|
||||
type GalleryMedia = {
|
||||
type: 'gallery'
|
||||
images: ComposerImage[]
|
||||
}
|
||||
|
||||
type VideoMedia = {
|
||||
type: 'video'
|
||||
video: VideoState
|
||||
@@ -59,7 +65,7 @@ type Link = {
|
||||
export type EmbedDraft = {
|
||||
// We'll always submit quote and actual media (images, video, gifs) chosen by the user.
|
||||
quote: Link | undefined
|
||||
media: ImagesMedia | VideoMedia | GifMedia | undefined
|
||||
media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined
|
||||
// This field may end up ignored if we have more important things to display than a link card:
|
||||
link: Link | undefined
|
||||
}
|
||||
@@ -154,7 +160,31 @@ export type ComposerAction =
|
||||
draftId: string
|
||||
}
|
||||
|
||||
export const MAX_IMAGES = 4
|
||||
/**
|
||||
* Threshold for picking between embed variants. <= this count uses the
|
||||
* legacy `app.bsky.embed.images` shape; > this count promotes to
|
||||
* `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the
|
||||
* legacy images embed entirely, this constant (and the variant split it
|
||||
* gates) should go away.
|
||||
*/
|
||||
export const LEGACY_IMAGES_EMBED_MAX = 4
|
||||
export const MAX_GALLERY_IMAGES = 10
|
||||
|
||||
/**
|
||||
* Picks the embed variant for a set of images. <=4 lands in the legacy
|
||||
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
|
||||
* Anything beyond the gallery cap is dropped by the hard slice; callers
|
||||
* should already have enforced the cap upstream (picker, paste, etc),
|
||||
* and the reducer logs a warning when the cap is exceeded so the UI
|
||||
* layer can surface a toast.
|
||||
*/
|
||||
function imagesToMediaVariant(
|
||||
images: ComposerImage[],
|
||||
): ImagesMedia | GalleryMedia {
|
||||
return images.length <= LEGACY_IMAGES_EMBED_MAX
|
||||
? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
|
||||
: {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
|
||||
}
|
||||
|
||||
export function composerReducer(
|
||||
state: ComposerState,
|
||||
@@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
}
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
const prevCount =
|
||||
prevMedia?.type === 'images' || prevMedia?.type === 'gallery'
|
||||
? prevMedia.images.length
|
||||
: 0
|
||||
const incomingCount = prevCount + action.images.length
|
||||
if (incomingCount > MAX_GALLERY_IMAGES) {
|
||||
// Defense in depth: callers (applyGalleryCap in Composer) should have
|
||||
// already trimmed and surfaced a toast. The hard slice in
|
||||
// imagesToMediaVariant still drops the excess so the cap holds.
|
||||
logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', {
|
||||
prevCount,
|
||||
incomingCount,
|
||||
dropped: incomingCount - MAX_GALLERY_IMAGES,
|
||||
})
|
||||
}
|
||||
if (!prevMedia) {
|
||||
nextMedia = {
|
||||
type: 'images',
|
||||
images: action.images.slice(0, MAX_IMAGES),
|
||||
}
|
||||
} else if (prevMedia.type === 'images') {
|
||||
nextMedia = {
|
||||
...prevMedia,
|
||||
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
|
||||
}
|
||||
nextMedia = imagesToMediaVariant(action.images)
|
||||
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
||||
nextMedia = imagesToMediaVariant([
|
||||
...prevMedia.images,
|
||||
...action.images,
|
||||
])
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
@@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
}
|
||||
case 'embed_update_image': {
|
||||
const prevMedia = state.embed.media
|
||||
if (prevMedia?.type === 'images') {
|
||||
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
|
||||
const updatedImage = action.image
|
||||
const nextMedia = {
|
||||
...prevMedia,
|
||||
@@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
case 'embed_remove_image': {
|
||||
const prevMedia = state.embed.media
|
||||
let nextLabels = state.labels
|
||||
if (prevMedia?.type === 'images') {
|
||||
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
|
||||
const removedImage = action.image
|
||||
let nextMedia: ImagesMedia | undefined = {
|
||||
...prevMedia,
|
||||
images: prevMedia.images.filter(img => {
|
||||
return img.source.id !== removedImage.source.id
|
||||
}),
|
||||
}
|
||||
if (nextMedia.images.length === 0) {
|
||||
const remainingImages = prevMedia.images.filter(img => {
|
||||
return img.source.id !== removedImage.source.id
|
||||
})
|
||||
let nextMedia: ImagesMedia | GalleryMedia | undefined
|
||||
if (remainingImages.length === 0) {
|
||||
nextMedia = undefined
|
||||
if (!state.embed.link) {
|
||||
nextLabels = []
|
||||
}
|
||||
} else {
|
||||
// Re-pick the variant so a gallery that shrinks to <=4 demotes
|
||||
// back to the legacy `app.bsky.embed.images` shape - keeps old
|
||||
// clients rendering it when possible.
|
||||
nextMedia = imagesToMediaVariant(remainingImages)
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
@@ -581,12 +626,9 @@ export function createComposerState({
|
||||
| AppBskyActorDefs.PostInteractionSettingsPref
|
||||
| undefined
|
||||
}): ComposerState {
|
||||
let media: ImagesMedia | undefined
|
||||
let media: ImagesMedia | GalleryMedia | undefined
|
||||
if (initImageUris?.length) {
|
||||
media = {
|
||||
type: 'images',
|
||||
images: createInitialImages(initImageUris),
|
||||
}
|
||||
media = imagesToMediaVariant(createInitialImages(initImageUris))
|
||||
}
|
||||
let quote: Link | undefined
|
||||
if (initQuoteUri) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
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 {isUriImage} from '#/lib/media/util'
|
||||
import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
@@ -93,10 +93,7 @@ export function TextInput({
|
||||
if (isUriImage(feature.uri)) {
|
||||
const res = await downloadAndResize({
|
||||
uri: feature.uri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
...IMAGE_SIZE_CONFIG_POSTS,
|
||||
timeout: 15e3,
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
|
||||
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
@@ -64,7 +64,7 @@ export function ComposerPrompt() {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
const selectionCountRemaining = MAX_IMAGES
|
||||
const selectionCountRemaining = MAX_GALLERY_IMAGES
|
||||
const {assets, canceled} = await sheetWrapper(
|
||||
openUnifiedPicker({selectionCountRemaining}),
|
||||
)
|
||||
@@ -76,7 +76,7 @@ export function ComposerPrompt() {
|
||||
if (assets.length > 0) {
|
||||
const imageUris = assets
|
||||
.filter(asset => asset.mimeType?.startsWith('image/'))
|
||||
.slice(0, MAX_IMAGES)
|
||||
.slice(0, MAX_GALLERY_IMAGES)
|
||||
.map(asset => ({
|
||||
uri: asset.uri,
|
||||
width: asset.width,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {
|
||||
useCameraPermission,
|
||||
@@ -330,6 +331,7 @@ let UserAvatar = ({
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
onLoad={onLoad}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
)}
|
||||
{!noBorder && <MediaInsetBorder style={borderStyle} />}
|
||||
@@ -394,6 +396,7 @@ let EditableUserAvatar = ({
|
||||
await openCamera({
|
||||
aspect: [1, 1],
|
||||
}),
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
),
|
||||
)
|
||||
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
||||
@@ -422,6 +425,7 @@ let EditableUserAvatar = ({
|
||||
shape: circular ? 'circle' : 'rectangle',
|
||||
aspectRatio: 1,
|
||||
}),
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -448,7 +452,7 @@ let EditableUserAvatar = ({
|
||||
|
||||
const onChangeEditImage = useCallback(
|
||||
async (image: ComposerImage) => {
|
||||
const compressed = await compressImage(image)
|
||||
const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB)
|
||||
onSelectNewAvatar(compressed)
|
||||
},
|
||||
[onSelectNewAvatar],
|
||||
|
||||
@@ -6,6 +6,7 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
@@ -62,6 +63,7 @@ export function UserBanner({
|
||||
await openCamera({
|
||||
aspect: [3, 1],
|
||||
}),
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
),
|
||||
)
|
||||
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
||||
@@ -83,6 +85,7 @@ export function UserBanner({
|
||||
imageUri: items[0].path,
|
||||
aspectRatio: 3 / 1,
|
||||
}),
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -108,7 +111,7 @@ export function UserBanner({
|
||||
|
||||
const onChangeEditImage = useCallback(
|
||||
async (image: ComposerImage) => {
|
||||
const compressed = await compressImage(image)
|
||||
const compressed = await compressImage(image, IMAGE_SIZE_CONFIG_2K_1MB)
|
||||
onSelectNewBanner?.(compressed)
|
||||
},
|
||||
[onSelectNewBanner],
|
||||
@@ -129,6 +132,7 @@ export function UserBanner({
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
@@ -216,6 +220,7 @@ export function UserBanner({
|
||||
blurRadius={moderation?.blur ? 100 : 0}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
|
||||
@@ -144,6 +144,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
|
||||
const [demoMode] = useDemoMode()
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
const isLabeler = profile?.associated?.labeler
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -276,9 +277,9 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isLabeler ? styles.profileIconSquare : styles.profileIcon,
|
||||
isAtMyProfile && [
|
||||
styles.onProfile,
|
||||
isLabeler ? styles.onProfileSquare : styles.onProfile,
|
||||
{
|
||||
borderColor: t.atoms.text.color,
|
||||
borderWidth: live ? 0 : 1,
|
||||
|
||||
@@ -67,9 +67,18 @@ export const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
profileIconSquare: {
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
messagesIcon: {},
|
||||
onProfile: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 100,
|
||||
},
|
||||
onProfileSquare: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ export function BottomBarWeb() {
|
||||
const unreadMessageCount = useUnreadMessageCount()
|
||||
const notificationCountStr = useUnreadNotifications()
|
||||
const aa = useAgeAssurance()
|
||||
const isLabeler = profile?.associated?.labeler
|
||||
|
||||
const showSignIn = useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
@@ -186,9 +187,13 @@ export function BottomBarWeb() {
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isLabeler
|
||||
? styles.profileIconSquare
|
||||
: styles.profileIcon,
|
||||
isActive && [
|
||||
styles.onProfile,
|
||||
isLabeler
|
||||
? styles.onProfileSquare
|
||||
: styles.onProfile,
|
||||
{borderColor: t.atoms.text.color},
|
||||
],
|
||||
]}>
|
||||
|
||||
@@ -175,6 +175,7 @@ function ProfileCard({minimal}: {minimal: boolean}) {
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.font_bold, a.text_sm, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
|
||||
Reference in New Issue
Block a user