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