Merge remote-tracking branch 'origin/main' into app-2288

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