Compare commits

..

27 Commits

Author SHA1 Message Date
vineyardbovines 9d3363f61e Pass IMAGE_SIZE_CONFIG_POSTS to gallery compressImage call 2026-06-04 17:13:43 -04:00
vineyardbovines c9e94e9b14 Merge remote-tracking branch 'origin/main' into gallery-embed 2026-06-04 17:12:43 -04:00
Eric Bailey 923e1635d8 V smol log cleanups (#10731) 2026-06-04 15:56:37 -05:00
Samuel Newman ded2ca3744 [Chat] Add chat invite as message embed type (#10728) 2026-06-04 13:11:03 -07:00
vineyardbovines 64890cc628 Retrigger CI
Investigating a typecheck failure that can't be reproduced locally.
2026-06-04 16:09:50 -04:00
DS Boyce 09fa93553b Fix useJoinRequestMutation (#10730) 2026-06-04 13:08:51 -07:00
DS Boyce b9637c0305 Improve error message for group chat join link (#10729) 2026-06-04 12:57:22 -07:00
Spence Pope 6677ae977c Show image count badge on in-feed carousel (#10719) 2026-06-04 14:25:35 -04:00
vineyardbovines 0da63326bd Address PR review comments
- MessageOverlays.tsx: revert to main (unrelated reactions-dialog fix
  that snuck in; will follow up separately)
- composer.ts: rename MAX_IMAGES -> LEGACY_IMAGES_EMBED_MAX to flag it
  as the legacy/gallery split threshold; replace stale TODO with a
  defense-in-depth note matching applyGalleryCap's role
- drafts/state/api.ts: update import + restore-path usage to new name
- Composer.tsx: extract duplicated onImageAdd cap/toast/dispatch logic
  into a shared useAddImagesWithCap hook, used by both ComposerPost
  and ComposerFooter
- MediaPreview.tsx: replace filter().slice(4).map() with a single-pass
  loop that stops at 4 tiles instead of walking a 10-image gallery
2026-06-04 13:50:40 -04:00
Spence Pope 58783ca5ae ensure alt text is scrollable (#10671)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 11:24:50 -05:00
Spence Pope d3c9ed027c Fix web carousel horizontal swipe triggering browser back/forward (#10718)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 11:23:16 -05:00
Eric Bailey 795d493dea Apply per-use-case image size configs (#10690)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:04 -05:00
Samuel Newman 66db349ef2 Switch dynamic app icon library to @bsky.app, fix build flakiness (#10724) 2026-06-04 18:46:49 +03:00
DS Boyce 08cd4e58b0 Add group chat join links to supported embed types (#10454) 2026-06-04 08:11:50 -07:00
Eric Bailey c79b5bfa08 Disable overscroll on Android to fix settle discrepancy (#10717) 2026-06-04 09:29:36 -05:00
heikadog 49f8e5031f Fix labeller highlight when selected on bottom bar (#10716) 2026-06-04 03:28:40 -07:00
surfdude29 9f200aab05 Use correct Plural macro in InviteLinkDialog (#10723) 2026-06-04 00:51:51 -07:00
surfdude29 a19d652a39 Fix invalid zero plural category and add plural formatting (#10722) 2026-06-03 23:50:56 -07:00
pfrazee ad4591eb29 Nightly source-language update 2026-06-04 03:21:17 +00:00
vineyardbovines 77c08164b7 Drop unnecessary IconName casts to satisfy lint
PR #10490 removed these as DynamicAppIcon.IconName casts because
@typescript-eslint/no-unnecessary-type-assertion flags them; my
review-feedback commit re-added them by mistake.
2026-06-03 19:26:17 -04:00
vineyardbovines 06298eb58a Close remaining gallery-embed review gaps
- drafts/state/api.ts: restore path now picks images vs gallery
  variant by count instead of trusting the server slot, mirroring
  imagesToMediaVariant. Save path was already correct in prior commits
- Composer.tsx: add applyGalleryCap helper and wire it into both
  onImageAdd callsites (covers picker, paste, camera, drag-drop) so
  the user gets a toast when adds would exceed MAX_GALLERY_IMAGES.
  Reducer-level logger.warn remains as defense in depth
- ImageLayoutGrid/Item: propagate isWithinQuote into GalleryItem so
  hideBadges also honors the explicit prop, matching the layout fix
- MessageOverlays: keep a snapshot of the last-known reactions message
  so the bottom sheet plays its close animation through when the
  underlying message is deleted; live updates while open are unchanged
2026-06-03 18:28:47 -04:00
vineyardbovines af2eec0f84 Address review feedback on gallery-embed branch
- Composer.tsx: thread gallery through reply pills (hasMedia),
  post:create/draft:save analytics, missingAltError, and publish-fail
  logger annotation
- composer/state/composer.ts: surface a logger.warn when
  embed_add_images would exceed MAX_GALLERY_IMAGES (hard slice
  preserved); ascii-only comments
- maybeApplyGalleryOffsetStyles.ts: scope PostGalleryEmbedEnable gate
  to image-embed branches so gallery posts always get no-content offsets
- MessageOverlays.tsx: store reactionsTargetId instead of a frozen
  message snapshot; look up the live message each render. Memoize
  reportSubject on reportTarget+convoId so ReportDialog's parseReportSubject
  memo survives convo updates. Replace open-after-mount boolean with a
  ref keyed to id so reopening the same message reliably re-triggers
- ImageEmbed.tsx + ImageLayoutGrid.tsx: thread isWithinQuote through
  the 2-4 image fallthrough; explicit prop overrides the viewContext
  heuristic
- parseReportSubject.ts: flip the image attribute true for gallery
  embeds (top-level and post_with_media), matching the existing images
  branches
- AppIconSettings/index.tsx: restore the IconName casts the previous
  change dropped; IconName is still a literal union
- composer/photos/Gallery.tsx: wrap to a 4-column grid for n>4 so
  10-image previews don't crush remove/alt-text controls
- lib/api/index.ts: normalize gallery upload log nouns to image/images
2026-06-03 18:23:33 -04:00
Samuel Newman 153772b760 expo-image tweaks (#10710) 2026-06-04 01:03:11 +03:00
vineyardbovines 4fe5f9da37 Fix createComposerState count-blind init, tighten gallery offset guard
B1: `createComposerState` always typed the initial media as `ImagesMedia`
regardless of `initImageUris.length`. With the picker cap raised to 10 in
ComposerPrompt + the URL intent handler not capping at all, the FAB and
deep-link flows could land the composer with 5-10 images in an `images`
embed, which `app.bsky.embed.images` rejects (`maxLength: 4`) on submit.

Extract `imagesToMediaVariant` and reuse from `createComposerState`,
`embed_add_images`, and `embed_remove_image` so the variant decision
("<=4 = images, >4 = gallery, cap at 10") lives in one place. The
remove-image demote branch collapses into the helper too — same effect,
less code.

W1: `maybeApplyGalleryOffsetStyles` only short-circuited single-image
galleries, applying offset for 0-item galleries when nothing renders
below. Loosened to `<= 1` (covers both 0 and 1 with the same intent).
2026-06-03 17:48:04 -04:00
vineyardbovines a222ec4de0 Merge main into gallery-embed, migrate off local shim
Bumps @atproto/api to 0.20.9, which ships the generated
`AppBskyEmbedGallery` namespace and `DraftPost.embedGallery`. Swaps every
`#/lib/api/gallery-embed-shim` import to `@atproto/api`, deletes the
shim, drops the local `DraftPostWithGallery` widen-types, and adds the
narrowing calls (`isViewImage`, `isDraftEmbedImage`) the real lexicon
unions require.

The merge also auto-resolves an inline image-restore block in
`drafts/state/api.ts` against main's typing tweak — main switched to
`satisfies ComposerImage` and `NonNullable<typeof img>`, which here move
into the existing `restoreDraftImages` helper that's now shared by both
the images and gallery restore paths.
2026-06-03 17:36:23 -04:00
vineyardbovines 6ac2e30c8f Drop carousel naming, cap gallery MediaPreview to 4 tiles
Renames `GRID_TO_CAROUSEL_THRESHOLD` -> `MAX_GRID_IMAGES` and
`carouselEnabled` -> `useExpandedLayout` in ImageEmbed so the render-path
decision reads as a count threshold rather than naming the component
that happens to live downstream today.

Also slices the gallery branch of MediaPreview to the first 4 items so a
10-image gallery doesn't overflow the inline notification/DM row.
2026-06-03 13:34:29 -04:00
vineyardbovines 9aea4362d5 Add app.bsky.embed.gallery embed type support
Wires the new gallery embed (atproto PR #4827) through the display and
compose pipelines while the lexicon is unpublished. Gallery posts render
via the existing carousel for >4 items and grid for <=4. Compose now
allows up to 10 images and auto-promotes images -> gallery above 4
(demotes back when count drops to <=4 so old clients still see legacy
embeds when possible). Drafts persist as the new `embedGallery` field.

Local shim types in `src/lib/api/gallery-embed-shim*.ts` mirror the
lexicon shape; delete both files once @atproto/api ships the generated
types.
2026-06-03 13:12:27 -04:00
97 changed files with 2207 additions and 695 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
+2 -2
View File
@@ -93,11 +93,12 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.8",
"@atproto/api": "0.20.9",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
@@ -123,7 +124,6 @@
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
+23 -23
View File
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.8
version: 0.20.8
specifier: 0.20.9
version: 0.20.9
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -256,6 +256,9 @@ importers:
'@bsky.app/alf':
specifier: ^0.1.14
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon':
specifier: ^1.8.5
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-guess-language':
specifier: ^0.2.8
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -331,9 +334,6 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.0
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.8':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
'@atproto/api@0.20.9':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1624,6 +1624,13 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/expo-dynamic-app-icon@1.8.5':
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@bsky.app/expo-guess-language@0.2.8':
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
peerDependencies:
@@ -2327,13 +2334,6 @@ packages:
'@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
'@mozzius/expo-dynamic-app-icon@1.8.1':
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.8':
'@atproto/api@0.20.9':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10446,6 +10446,14 @@ snapshots:
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-responsive: 10.0.1(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -11449,14 +11457,6 @@ snapshots:
dependencies:
moo: 0.5.3
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
+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(
+34 -1
View File
@@ -1,6 +1,10 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {
AppBskyEmbedGallery,
type AppBskyEmbedImages,
type AppBskyFeedDefs,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
@@ -47,6 +51,34 @@ export function Embed({
)}
</Outer>
)
} else if (e.type === 'gallery') {
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
// a 10-image gallery doesn't blow out the row width. Single pass instead
// of filter().slice().map() so we stop at 4 viewable items rather than
// walking every item in a 10-image gallery.
const tiles: React.ReactNode[] = []
for (const item of e.view.items) {
if (tiles.length >= 4) break
if (!AppBskyEmbedGallery.isViewImage(item)) continue
if (peekable) {
const image: AppBskyEmbedImages.ViewImage = {
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
} else {
tiles.push(
<ImageItem
key={item.thumbnail}
thumbnail={item.thumbnail}
alt={item.alt}
/>,
)
}
}
return <Outer style={style}>{tiles}</Outer>
} else if (e.type === 'link') {
if (!e.view.external.thumb) return null
if (!isGifEmbed(e.view.external.uri)) return null
@@ -129,6 +161,7 @@ export function ImageItem({
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
@@ -0,0 +1,48 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {atoms as a} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
/**
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
* a `bsky.app/c/<code>` link posted to the feed) as a join request card,
* falling back to a plain external embed if the invite can't be resolved.
*/
export function ChatInviteEmbed({
code,
link,
onOpen,
style,
}: {
code: string
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
return (
<ChatInvite.Root code={code}>
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
</ChatInvite.Root>
)
}
function ChatInviteEmbedBody({
link,
onOpen,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const {error} = ChatInvite.useChatInvite()
if (error) {
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
}
return <JoinRequestEmbedBody style={[a.mt_sm, style]} onOpen={onOpen} />
}
@@ -59,7 +59,7 @@ export const ExternalEmbed = ({
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
void shareUrl(link.uri)
}
}, [link.uri, playHaptic])
@@ -108,6 +108,7 @@ export const ExternalEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
+19 -4
View File
@@ -2,6 +2,7 @@ import {useRef} from 'react'
import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
@@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
const MAX_GRID_IMAGES = 4
export function ImageEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
embed: EmbedType<'images'> | EmbedType<'gallery'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const images: AppBskyEmbedImages.ViewImage[] =
embed.type === 'gallery'
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
: embed.view.images
const useExpandedLayout =
embed.type === 'gallery'
? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
@@ -109,7 +123,7 @@ export function ImageEmbed({
)
}
if (galleryEnabled) {
if (useExpandedLayout) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
@@ -130,6 +144,7 @@ export function ImageEmbed({
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/>
</View>
)
@@ -0,0 +1,113 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const JOIN_REQUEST_EMBED_HEIGHT = 140
/**
* The "join request" presentation of a chat invite, used as a post embed (in
* feeds and the post composer). Composes the headless `ChatInvite` primitive:
* pass either a `code` to fetch by, or an already-resolved `preview` as the
* initial data to avoid a loading flash.
*/
export function JoinRequestEmbed({
code,
preview,
style,
onOpen,
}: {
code?: string
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode = code ?? preview?.code
if (!resolvedCode) return null
return (
<ChatInvite.Root code={resolvedCode} initialPreview={preview}>
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root>
)
}
/**
* The context-consuming presentation (loading / no-longer-available / card +
* join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
* to add an error fallback) can render it without nesting another Root.
*/
export function JoinRequestEmbedBody({
style,
onOpen,
}: {
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
<View
style={[
a.align_center,
a.justify_center,
a.p_lg,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
}
if (!preview) {
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.gap_xs,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
}
return (
<View
style={[
a.justify_between,
a.border,
a.rounded_lg,
a.p_lg,
a.gap_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<ChatInvite.Card size="large" />
<ChatInvite.JoinButton onPress={onOpen} />
</View>
)
}
@@ -166,6 +166,7 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -355,6 +356,7 @@ export function PublicationCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_md,
@@ -385,7 +387,7 @@ export function PublicationCard({
<View style={[a.pointer_events_none]}>
{view.description && (
<View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
@@ -616,6 +618,7 @@ export function PublicationFooter({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_sm,
+20 -1
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'
@@ -52,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
switch (embed.type) {
case 'images':
case 'gallery':
case 'link':
case 'video': {
return <MediaEmbed embed={embed} {...rest} />
@@ -87,7 +90,8 @@ function MediaEmbed({
embed: TEmbed
}) {
switch (embed.type) {
case 'images': {
case 'images':
case 'gallery': {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -110,6 +114,21 @@ function MediaEmbed({
</ContentHider>
)
}
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
if (chatInviteCode) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<ChatInviteEmbed
code={chatInviteCode}
link={embed.view.external}
onOpen={rest.onOpen}
style={rest.style}
/>
</ContentHider>
)
}
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -60,6 +60,7 @@ export function FindContactsBannerNUX() {
a.self_end,
a.mt_sm,
]}
useAppleWebpCodec
/>
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text
@@ -27,6 +27,7 @@ export function ContactsHeroImage() {
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
useAppleWebpCodec
/>
</View>
)
@@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() {
alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -124,6 +124,7 @@ export function BookmarksAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -101,6 +101,7 @@ export function DraftsAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
@@ -78,6 +78,7 @@ export function FindContactsAnnouncement() {
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() {
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
useAppleWebpCodec
/>
</View>
@@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() {
alt={_(
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
)}
useAppleWebpCodec
/>
</View>
@@ -150,6 +150,7 @@ export function LiveNowBetaDialog() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
</View>
+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 />
+35
View File
@@ -343,6 +343,11 @@ export function Gallery({
marginLeft: -insetLeft,
width,
},
// Prevent horizontal trackpad/wheel swipes from triggering the
// browser's back/forward overscroll-navigation gesture. Handles
// Chrome and Firefox; Safari is handled via the wheel listener in
// usePointerHandlers.web.ts since it ignores overscroll-behavior.
web({overscrollBehaviorX: 'contain'}),
]}
contentContainerStyle={{
gap: ITEM_GAP,
@@ -483,8 +488,38 @@ function GalleryImage({
height: e.source.height,
})
}}
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
top: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
@@ -1,4 +1,5 @@
import {
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
@@ -27,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
@@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
return
}
// The gate only controls whether legacy image embeds opt into the new
// expanded gallery layout. Gallery embeds always render expanded by item
// count, so their offset must apply regardless of the gate.
const isPostGalleryEmbedEnabled = features.isOn(
Features.PostGalleryEmbedEnable,
)
/*
* First check if we even have images
*/
@@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles(
embed,
AppBskyEmbedImages.isMain,
)
const isGalleryEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed,
AppBskyEmbedGallery.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
@@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles(
)
let hasImages = false
if (isImageEmbed) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isGalleryEmbed) {
// single (or empty) gallery - no offset needed
if (embed.items.length <= 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
@@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles(
AppBskyEmbedImages.isMain,
)
) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery
if (embed.media.images.length === 1) return
}
if (
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed.media,
AppBskyEmbedGallery.isMain,
)
) {
// single (or empty) gallery - no offset needed
if (embed.media.items.length <= 1) return
}
hasImages = true
}
if (!hasImages) return
@@ -4,6 +4,7 @@ import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
import {IS_WEB_SAFARI} from '#/env'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
@@ -246,12 +247,64 @@ export function usePointerHandlers({
}
}
/*
* Safari does not support `overscroll-behavior`, so a horizontal trackpad
* swipe over the carousel can trigger the browser's back/forward
* navigation gesture. We intercept predominantly-horizontal wheel events
* and apply the scroll ourselves, calling preventDefault to suppress the
* history-nav gesture. Vertical-dominant wheel events are left untouched so
* normal page scroll still works. Chrome/Firefox are covered by the
* `overscrollBehaviorX: 'contain'` style on the FlatList.
*
* Listener must be non-passive so preventDefault is honored.
*/
const onWheel = (e: WheelEvent) => {
if (!IS_WEB_SAFARI) return
// Only act on predominantly-horizontal scrolls. Vertical-dominant events
// are page scroll and must not be swallowed.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
e.preventDefault()
// Cancel any in-progress settle tween so manual scrolling feels direct.
if (stopTween) {
stopTween()
stopTween = null
}
if (overscrollX !== 0) clearOverscroll()
const maxScroll = el.scrollWidth - el.clientWidth
const next = Math.max(0, Math.min(el.scrollLeft + e.deltaX, maxScroll))
scrollTo(next)
// Keep the active index in sync so keyboard/lightbox stay correct, but
// only settle when it actually changes - onSettle moves focus, which we
// don't want to thrash on every wheel tick.
let accumulated = 0
let index = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (next < accumulated + w / 2) {
index = i
break
}
accumulated += w
if (i === imageCount - 1) index = i
}
if (index !== localIndex) {
localIndex = index
onSettle(index)
}
}
el.addEventListener('mousedown', onMouseDown)
el.addEventListener('wheel', onWheel, {passive: false})
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('wheel', onWheel)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
+15 -7
View File
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
export function ImageLayoutGrid({
style,
isWithinQuote: isWithinQuoteProp,
...props
}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints()
const gap =
const isWithinQuote =
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? gtMobile
? a.gap_xs
: a.gap_2xs
: a.gap_xs
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
return (
<View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} />
<ImageLayoutGridInner
{...props}
gap={gap}
isWithinQuote={isWithinQuote}
/>
</View>
</View>
)
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
gap: {gap: number}
}
@@ -29,6 +29,7 @@ interface Props {
onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
@@ -42,6 +43,7 @@ export function GalleryItem({
onPressIn,
onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle,
containerRefs,
thumbDimsRef,
@@ -52,6 +54,7 @@ export function GalleryItem({
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
@@ -106,6 +109,7 @@ export function GalleryItem({
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession,
staleTime: 0,
})
const {mutate: joinGroupChat, isPending: isJoinPending} =
@@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
) {
errorMessage = l`The member limit has been reached.`
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
errorMessage = l`You have been removed from this group.`
errorMessage = l`You have been previously removed from this group and 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,7 +87,10 @@ export function parseReportSubject(
reply: !!record.reply,
image:
embed.type === 'images' ||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
embed.type === 'gallery' ||
(embed.type === 'post_with_media' &&
(embed.media.type === 'images' ||
embed.media.type === 'gallery')),
video:
embed.type === 'video' ||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
@@ -87,6 +87,7 @@ function Inner({
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
useAppleWebpCodec
/>
</View>
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
useAppleWebpCodec
/>
<LinearGradient
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
useAppleWebpCodec
/>
<LinearGradient
@@ -54,6 +54,7 @@ export function LinkPreview({
contentFit="cover"
onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)}
useAppleWebpCodec
/>
)}
{linkMeta && (!linkMeta.image || imageLoadError) && (
@@ -147,6 +147,7 @@ export function LiveStatus({
contentFit="cover"
style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<LiveIndicator
size="large"
+52 -3
View File
@@ -1,6 +1,7 @@
import {
type $Typed,
type AppBskyEmbedExternal,
type AppBskyEmbedGallery,
type AppBskyEmbedImages,
type AppBskyEmbedRecord,
type AppBskyEmbedRecordWithMedia,
@@ -21,6 +22,7 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
@@ -177,7 +179,8 @@ export async function post(
writes: writes,
validate: true,
})
} catch (e: any) {
} catch (err) {
const e = err as Error
logger.error(`Failed to create post`, {
safeMessage: e.message,
})
@@ -252,6 +255,7 @@ async function resolveEmbed(
onStateChange: ((state: string) => void) | undefined,
): Promise<
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main>
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedRecord.Main>
@@ -311,6 +315,7 @@ async function resolveMedia(
): Promise<
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main>
| undefined
> {
@@ -323,7 +328,10 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
@@ -338,6 +346,34 @@ async function resolveMedia(
images,
}
}
if (embedDraft.media?.type === 'gallery') {
const imagesDraft = embedDraft.media.images
logger.debug(`Uploading images`, {
count: imagesDraft.length,
})
onStateChange?.(t`Uploading images...`)
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
$type: 'app.bsky.embed.gallery#image' as const,
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
}
}),
)
return {
$type: 'app.bsky.embed.gallery',
items,
}
}
if (
embedDraft.media?.type === 'video' &&
embedDraft.media.video.status === 'done'
@@ -427,6 +463,16 @@ async function resolveMedia(
},
}
}
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
return {
$type: 'app.bsky.embed.external',
external: {
uri: resolvedLink.uri,
title: resolvedLink.view.name,
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
},
}
}
}
return undefined
}
@@ -470,6 +516,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
}
// Returns a transformed version of the object for use in DAG-CBOR.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function prepareForHashing(v: any): any {
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
// the API client will convert this for you but we're hashing in the client,
@@ -492,9 +539,10 @@ function prepareForHashing(v: any): any {
// Walk through plain objects
if (isPlainObject(v)) {
const obj: any = {}
const obj: Record<string, unknown> = {}
let pure = true
for (const key in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let value = v[key]
// `value` is undefined
if (value === undefined) {
@@ -513,6 +561,7 @@ function prepareForHashing(v: any): any {
return v
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPlainObject(v: any): boolean {
if (typeof v !== 'object' || v === null) {
return false
+25 -5
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
+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"
/>
@@ -5,8 +5,7 @@ import {
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -178,11 +177,7 @@ export function InviteLinkDialog({
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, {
one: '# person',
other: '# people',
})}
.
<Plural value={convo.details.memberLimit} other="# people" />.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
@@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers'
import {useEmail} from '#/state/email-verification'
import {
useMessageDraft,
@@ -233,7 +233,11 @@ export function MessageComposer({
}}
onChange={handleChange}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
if (
facet.type === 'url' &&
(isBskyPostUrl(facet.value) ||
isBskyChatInviteUrl(facet.value))
) {
setEmbed(facet.value)
}
}}
@@ -18,6 +18,8 @@ import {
} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyChatInviteUrl,
isBskyPostUrl,
makeRecordUri,
} from '#/lib/strings/url-helpers'
@@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview'
@@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
/**
* The embed staged in the message composer. A message can carry at most one
* embed: either a quoted post or a group chat invite link.
*/
export type MessageEmbedState =
| {type: 'post'; uri: string}
| {type: 'invite'; code: string}
export function useMessageEmbed() {
const route =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed
const [embedUri, setEmbedUri] = useState(embedFromParams)
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
)
if (embedFromParams && embedUri !== embedFromParams) {
setEmbedUri(embedFromParams)
if (embedFromParams && embed?.type !== 'post') {
setEmbedState({type: 'post', uri: embedFromParams})
}
return {
embedUri,
embed,
setEmbed: useCallback(
(embedUrl: string | undefined) => {
if (!embedUrl) {
// Only the post embed is reflected in the route param (used by the
// share-to-DM intent flow); invites are local-only.
navigation.setParams({embed: ''})
setEmbedUri(undefined)
setEmbedState(undefined)
return
}
if (embedFromParams) return
const url = convertBskyAppUrlIfNeeded(embedUrl)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
if (isBskyChatInviteUrl(embedUrl)) {
const code = getChatInviteCodeFromUrl(embedUrl)
if (code) {
setEmbedState({type: 'invite', code})
}
return
}
setEmbedUri(uri)
if (isBskyPostUrl(embedUrl)) {
const url = convertBskyAppUrlIfNeeded(embedUrl)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
setEmbedState({type: 'post', uri})
}
},
[embedFromParams, navigation],
),
@@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets(
for (const facet of rt.facets ?? []) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
if (
AppBskyRichtextFacet.isLink(feature) &&
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
) {
uriFromFacet = feature.uri
break
}
@@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets(
}
export function MessageInputEmbed({
embedUri,
embed,
setEmbed,
}: {
embedUri: string | undefined
embed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
}) {
const onRemove = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setEmbed(undefined)
}, [setEmbed])
if (!embed) {
return null
}
switch (embed.type) {
case 'post':
return <MessageInputPostEmbed uri={embed.uri} onRemove={onRemove} />
case 'invite':
return <MessageInputInviteEmbed code={embed.code} onRemove={onRemove} />
}
}
function MessageInputPostEmbed({
uri,
onRemove,
}: {
uri: string
onRemove: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {data: post, status} = usePostQuery(embedUri)
const {data: post, status} = usePostQuery(uri)
const moderationOpts = useModerationOpts()
const moderation = useMemo(
@@ -134,15 +185,6 @@ export function MessageInputEmbed({
return {rt: undefined, record: undefined}
}, [post])
if (!embedUri) {
return null
}
const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setEmbed(undefined)
}
switch (status) {
case 'pending': {
return (
@@ -220,6 +262,71 @@ export function MessageInputEmbed({
}
}
function MessageInputInviteEmbed({
code,
onRemove,
}: {
code: string
onRemove: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<ChatInvite.Root code={code}>
<View
style={[
a.flex_1,
t.atoms.border_contrast_high,
a.rounded_md,
a.border,
a.p_sm,
a.mt_sm,
a.mx_sm,
]}>
<MessageInputInviteEmbedBody />
<Button
label={l`Remove embed`}
onPress={onRemove}
style={[
a.absolute,
{top: 10, right: 8},
a.px_2xs,
{transform: [{translateY: -2}]},
]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
</View>
</ChatInvite.Root>
)
}
function MessageInputInviteEmbedBody() {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Loader />
</View>
)
}
if (!preview) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
<Trans>Could not load invite</Trans>
</Text>
</View>
)
}
return <ChatInvite.Card size="small" />
}
function SimpleContainer({
children,
onRemove,
@@ -28,6 +28,7 @@ import {
type AppBskyEmbedRecord,
AppBskyRichtextFacet,
ChatBskyConvoDefs,
type ChatBskyEmbedJoinLink,
RichText,
} from '@atproto/api'
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
@@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyPostUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
@@ -46,9 +48,10 @@ import {
useConvoActive,
} from '#/state/messages/convo'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
import {useGetPost} from '#/state/queries/post'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useAgent} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
@@ -131,8 +134,10 @@ export function MessagesList({
const ax = useAnalytics()
const convoState = useConvoActive()
const agent = useAgent()
const {hasSession} = useSession()
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
const getJoinLinkPreview = useGetJoinLinkPreview()
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
const t = useTheme()
const textInputId = 'chat-input-' + useId()
@@ -348,12 +353,38 @@ export function MessagesList({
// we want to remove the post link from the text, re-trim, then detect facets
rt.detectFacetsWithoutResolution()
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined
let embed:
| $Typed<AppBskyEmbedRecord.Main>
| $Typed<ChatBskyEmbedJoinLink.Main>
| undefined
let embedView:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| undefined
if (embedUri) {
// Find the embedded link facet and, if it's at the start or end of the
// message, remove it from the text (the embed card replaces it).
const stripLinkFacet = (predicate: (uri: string) => boolean) => {
const linkFacet = rt.facets?.find(facet =>
facet.features.find(
feature =>
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
),
)
if (linkFacet) {
const isAtStart = linkFacet.index.byteStart === 0
const isAtEnd =
linkFacet.index.byteEnd === rt.unicodeText.graphemeLength
if (isAtStart || isAtEnd) {
rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
if (messageEmbed?.type === 'post') {
try {
const post = await getPost({uri: embedUri})
const post = await getPost({uri: messageEmbed.uri})
if (post) {
embed = {
$type: 'app.bsky.embed.record',
@@ -368,42 +399,34 @@ export function MessagesList({
record: createEmbedViewRecordFromPost(post),
}
// look for the embed uri in the facets, so we can remove it from the text
const postLinkFacet = rt.facets?.find(facet => {
return facet.features.find(feature => {
if (AppBskyRichtextFacet.isLink(feature)) {
if (isBskyPostUrl(feature.uri)) {
const url = convertBskyAppUrlIfNeeded(feature.uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
}
}
return false
})
stripLinkFacet(uri => {
if (!isBskyPostUrl(uri)) return false
const url = convertBskyAppUrlIfNeeded(uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
})
if (postLinkFacet) {
const isAtStart = postLinkFacet.index.byteStart === 0
const isAtEnd =
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
// remove the post link from the text
if (isAtStart || isAtEnd) {
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
}
} else if (messageEmbed?.type === 'invite') {
const code = messageEmbed.code
embed = {
$type: 'chat.bsky.embed.joinLink',
code,
}
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
if (joinLinkPreview) {
embedView = {
$type: 'chat.bsky.embed.joinLink#view',
joinLinkPreview,
}
}
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
}
await rt.detectFacets(agent)
@@ -424,7 +447,16 @@ export function MessagesList({
embedView,
)
},
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
[
agent,
convoState,
messageEmbed,
getPost,
getJoinLinkPreview,
hasSession,
hasScrolled,
setHasScrolled,
],
)
const scrollToEndOnPress = useCallback(() => {
@@ -595,11 +627,11 @@ export function MessagesList({
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
embed={messageEmbed}
setEmbed={setEmbed}
/>
</MessageComposer>
@@ -607,11 +639,11 @@ export function MessagesList({
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
embed={messageEmbed}
setEmbed={setEmbed}
/>
</MessageInput>
@@ -83,6 +83,7 @@ function Page({
style={[a.w_full, a.aspect_square]}
alt={alt}
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
useAppleWebpCodec
/>
{page === 1 && (
<Image
@@ -97,6 +98,7 @@ function Page({
},
]}
accessibilityIgnoresInvertColors
useAppleWebpCodec
alt={_(msg`Your profile picture`)}
/>
)}
+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
+73 -22
View File
@@ -1,4 +1,9 @@
import {AtpAgent} from '@atproto/api'
import {useCallback} from 'react'
import {
AtpAgent,
type ChatBskyGroupDefs,
type ChatBskyGroupGetJoinLinkPreviews,
} from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
@@ -17,12 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: {
persistedVersion: 1,
})
export function useJoinLinkPreviewsQuery({
async function fetchJoinLinkPreviews({
agent,
codes,
hasSession,
}: {
agent: AtpAgent
codes: string[]
hasSession: boolean
}) {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
}
export function useJoinLinkPreviewsQuery({
codes,
hasSession,
staleTime = STALE.MINUTES.ONE,
initialData,
}: {
codes?: string[]
hasSession: boolean
staleTime?: number
/**
* Seed the query with an already-known preview (e.g. a DM message embed
* already carries the resolved view), avoiding a duplicate fetch.
*/
initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema
}) {
const agent = useAgent()
@@ -31,21 +63,15 @@ export function useJoinLinkPreviewsQuery({
queryFn: async () => {
if (!codes) throw new Error('No invite code')
try {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
return await fetchJoinLinkPreviews({agent, codes, hasSession})
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
throw error
}
},
enabled: codes != null && codes.length > 0,
staleTime: STALE.SECONDS.FIFTEEN,
staleTime,
initialData,
})
}
@@ -56,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() {
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
return queryClient.prefetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
queryFn: async () => {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
},
staleTime: STALE.SECONDS.FIFTEEN,
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
staleTime: STALE.MINUTES.ONE,
})
}
}
/**
* Imperatively fetch (or read from cache) a single join link preview by code.
* Used when sending a DM invite embed so we can build an optimistic view.
* Returns undefined if the preview can't be resolved.
*/
export function useGetJoinLinkPreview() {
const agent = useAgent()
const queryClient = useQueryClient()
return useCallback(
async ({
code,
hasSession,
}: {
code: string
hasSession: boolean
}): Promise<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => {
try {
const data = await queryClient.fetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
queryFn: () =>
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
staleTime: STALE.MINUTES.ONE,
})
return data.joinLinkPreviews[0]
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
return undefined
}
},
[agent, queryClient],
)
}
+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}) => {
@@ -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})
}
+10
View File
@@ -1,6 +1,7 @@
import {
type $Typed,
AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
@@ -47,6 +48,10 @@ export type Embed =
type: 'images'
view: $Typed<AppBskyEmbedImages.View>
}
| {
type: 'gallery'
view: $Typed<AppBskyEmbedGallery.View>
}
| {
type: 'link'
view: $Typed<AppBskyEmbedExternal.View>
@@ -122,6 +127,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
type: 'images',
view: embed,
}
} else if (AppBskyEmbedGallery.isView(embed)) {
return {
type: 'gallery',
view: embed,
}
} else if (AppBskyEmbedExternal.isView(embed)) {
return {
type: 'link',
+95 -29
View File
@@ -159,7 +159,7 @@ import {
composerReducer,
createComposerState,
type EmbedDraft,
MAX_IMAGES,
MAX_GALLERY_IMAGES,
type PostAction,
type PostDraft,
type ThreadDraft,
@@ -178,6 +178,65 @@ type CancelRef = {
onPressCancel: () => void
}
function applyGalleryCap(
currentCount: number,
incoming: ComposerImage[],
):
| {status: 'full'}
| {status: 'partial'; accepted: ComposerImage[]; dropped: number}
| {status: 'ok'; accepted: ComposerImage[]} {
const remaining = MAX_GALLERY_IMAGES - currentCount
if (remaining <= 0) {
return {status: 'full'}
}
if (incoming.length > remaining) {
return {
status: 'partial',
accepted: incoming.slice(0, remaining),
dropped: incoming.length - remaining,
}
}
return {status: 'ok', accepted: incoming}
}
function useAddImagesWithCap(
currentCount: number,
dispatchPostAction: (action: PostAction) => void,
) {
const {t: l} = useLingui()
return useCallback(
(next: ComposerImage[]) => {
const result = applyGalleryCap(currentCount, next)
if (result.status === 'full') {
Toast.show(
l({
message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`,
comment:
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
}),
{type: 'warning'},
)
return
}
if (result.status === 'partial') {
Toast.show(
l({
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
comment:
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
}),
{type: 'warning'},
)
}
dispatchPostAction({
type: 'embed_add_images',
images: result.accepted,
})
},
[currentCount, dispatchPostAction, l],
)
}
type Props = ComposerOpts
export const ComposePost = ({
replyTo,
@@ -611,7 +670,11 @@ export const ComposePost = ({
ax.metric('draft:save', {
isNewDraft,
hasText: posts.some(p => p.richtext.text.trim().length > 0),
hasImages: posts.some(p => p.embed.media?.type === 'images'),
hasImages: posts.some(
p =>
p.embed.media?.type === 'images' ||
p.embed.media?.type === 'gallery',
),
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
hasQuote: posts.some(p => !!p.embed.quote),
@@ -780,7 +843,10 @@ export const ComposePost = ({
for (let i = 0; i < thread.posts.length; i++) {
const media = thread.posts[i].embed.media
if (media) {
if (media.type === 'images' && media.images.some(img => !img.alt)) {
if (
(media.type === 'images' || media.type === 'gallery') &&
media.images.some(img => !img.alt)
) {
return l`One or more images is missing alt text.`
}
if (media.type === 'gif' && !media.alt) {
@@ -931,7 +997,9 @@ export const ComposePost = ({
logger.error(e, {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p => p.embed.media?.type === 'images',
p =>
p.embed.media?.type === 'images' ||
p.embed.media?.type === 'gallery',
),
})
@@ -953,7 +1021,8 @@ export const ComposePost = ({
for (let post of filteredThread.posts) {
ax.metric('post:create', {
imageCount:
post.embed.media?.type === 'images'
post.embed.media?.type === 'images' ||
post.embed.media?.type === 'gallery'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
@@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({
[dispatch, post.id],
)
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
dispatchPost({
type: 'embed_add_images',
images: next,
})
},
[dispatchPost],
)
const postImagesCount =
post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
? post.embed.media.images.length
: 0
const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost)
const onNewLink = useCallback(
(uri: string) => {
@@ -1708,7 +1773,7 @@ function ComposerEmbeds({
const video = embed.media?.type === 'video' ? embed.media.video : null
return (
<>
{embed.media?.type === 'images' && (
{(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
<Gallery images={embed.media.images} dispatch={dispatch} />
)}
@@ -1819,7 +1884,11 @@ function ComposerPills({
}) {
const t = useTheme()
const media = post.embed.media
const hasMedia = media?.type === 'images' || media?.type === 'video'
const hasMedia =
media?.type === 'images' ||
media?.type === 'gallery' ||
media?.type === 'gif' ||
media?.type === 'video'
const hasLink = !!post.embed.link
// Don't render anything if no pills are going to be displayed
@@ -1908,15 +1977,16 @@ function ComposerFooter({
>(undefined)
const media = post.embed.media
const images = media?.type === 'images' ? media.images : []
const images =
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
const video = media?.type === 'video' ? media.video : null
const isMaxImages = images.length >= MAX_IMAGES
const isMaxImages = images.length >= MAX_GALLERY_IMAGES
const isMaxVideos = !!video
let selectedAssetsCount = 0
let isMediaSelectionDisabled = false
if (media?.type === 'images') {
if (media?.type === 'images' || media?.type === 'gallery') {
isMediaSelectionDisabled = isMaxImages
selectedAssetsCount = images.length
} else if (media?.type === 'video') {
@@ -1926,15 +1996,7 @@ function ComposerFooter({
isMediaSelectionDisabled = !!media
}
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
dispatch({
type: 'embed_add_images',
images: next,
})
},
[dispatch],
)
const onImageAdd = useAddImagesWithCap(images.length, dispatch)
const onSelectGif = useCallback(
(gif: Gif) => {
@@ -2017,7 +2079,11 @@ function ComposerFooter({
autoOpen={openGallery}
/>
<OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media}
disabled={
media?.type === 'images' || media?.type === 'gallery'
? isMaxImages
: !!media
}
onAdd={onImageAdd}
/>
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
+35 -15
View File
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
@@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const images = useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) {
return embed.images
} else if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedImages.isView(embed.media)
) {
return embed.media.images
} else if (AppBskyEmbedGallery.isView(embed)) {
return galleryItemsToImages(embed.items)
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
if (AppBskyEmbedImages.isView(embed.media)) {
return embed.media.images
} else if (AppBskyEmbedGallery.isView(embed.media)) {
return galleryItemsToImages(embed.media.items)
}
}
}, [embed])
@@ -129,6 +133,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
)
}
function galleryItemsToImages(
items: AppBskyEmbedGallery.View['items'],
): AppBskyEmbedImages.ViewImage[] {
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
// the existing layout switch valid for galleries up to 10 items.
return items
.filter(AppBskyEmbedGallery.isViewImage)
.slice(0, 4)
.map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
}
function ComposerReplyToImages({
images,
}: {
@@ -151,8 +171,8 @@ function ComposerReplyToImages({
<Image
source={{uri: images[0].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
)) ||
(images.length === 2 && (
@@ -160,14 +180,14 @@ function ComposerReplyToImages({
<Image
source={{uri: images[0].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<Image
source={{uri: images[1].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
</View>
)) ||
@@ -176,21 +196,21 @@ function ComposerReplyToImages({
<Image
source={{uri: images[0].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Image
source={{uri: images[1].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<Image
source={{uri: images[2].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
</View>
</View>
@@ -201,28 +221,28 @@ function ComposerReplyToImages({
<Image
source={{uri: images[0].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<Image
source={{uri: images[1].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
</View>
<View style={[a.flex_1, a.flex_row, a.gap_2xs]}>
<Image
source={{uri: images[2].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<Image
source={{uri: images[3].thumb}}
style={[a.flex_1]}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
</View>
</View>
+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"
+11 -8
View File
@@ -16,7 +16,7 @@ import {
} from '#/lib/hooks/usePermissions'
import {openUnifiedPicker} from '#/lib/media/picker'
import {extractDataUriMime} from '#/lib/media/util'
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
@@ -393,7 +393,9 @@ export function SelectMediaButton({
const t = useTheme()
const hasAutoOpened = useRef(false)
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
// Picker uses the gallery cap; the reducer decides which embed variant
// to land in based on the final image count.
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount
const processSelectedAssets = useCallback(
async (rawAssets: ImagePickerAsset[]) => {
@@ -419,10 +421,10 @@ export function SelectMediaButton({
),
[SelectedAssetError.MaxImages]: _(
msg({
message: `You can select up to ${plural(MAX_IMAGES, {
message: `You can select up to ${plural(MAX_GALLERY_IMAGES, {
other: '# images',
})} in total.`,
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
comment: `Error message for maximum number of images that can be selected to add to a post.`,
}),
),
[SelectedAssetError.MaxVideos]: _(
@@ -507,10 +509,11 @@ export function SelectMediaButton({
)}
accessibilityHint={_(
msg({
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, {
other: '# images',
})}, or a single video or GIF.`,
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
message: `Opens device gallery to select up to ${plural(
MAX_GALLERY_IMAGES,
{other: '# images'},
)}, or a single video or GIF.`,
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`,
}),
)}
style={a.p_sm}
+112 -47
View File
@@ -1,7 +1,7 @@
/**
* Type converters for Draft API - convert between ComposerState and server Draft types.
*/
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {resolveLink} from '#/lib/api/resolve'
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
import {
type ComposerState,
type EmbedDraft,
LEGACY_IMAGES_EMBED_MAX,
type PostDraft,
} from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video'
@@ -115,6 +116,16 @@ async function postDraftToServerPost(
post.embed.media.images,
localRefPaths,
)
} else if (post.embed.media.type === 'gallery') {
draftPost.embedGallery = {
$type: 'app.bsky.draft.defs#draftEmbedGallery',
items: serializeImages(post.embed.media.images, localRefPaths).map(
img => ({
$type: 'app.bsky.draft.defs#draftEmbedImage' as const,
...img,
}),
),
}
} else if (post.embed.media.type === 'video') {
const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) {
@@ -269,6 +280,59 @@ function serializeGif(gifMedia: {
}
}
/**
* Restore an array of draft image refs back to ComposerImages. Shared by
* both the `embedImages` and `embedGallery` paths in draftToComposerPosts.
*/
async function restoreDraftImages(
draftImages: AppBskyDraftDefs.DraftEmbedImage[],
loadedMedia: Map<string, string>,
): Promise<ComposerImage[]> {
const imagePromises = draftImages.map(async img => {
const path = loadedMedia.get(img.localRef.path)
if (!path) {
return null
}
let width = 0
let height = 0
try {
const dims = await getImageDim(path)
width = dims.width
height = dims.height
} catch (e) {
logger.warn('Failed to get image dimensions', {
path,
error: e,
})
}
logger.debug('restoring image with localRefPath', {
localRefPath: img.localRef.path,
loadedPath: path,
width,
height,
})
return {
alt: img.alt || '',
// Preserve the original localRefPath for reuse when saving
localRefPath: img.localRef.path,
source: {
id: nanoid(),
path,
width,
height,
mime: 'image/jpeg',
},
} satisfies ComposerImage
})
return (await Promise.all(imagePromises)).filter(
(img): img is NonNullable<typeof img> => img !== null,
)
}
/**
* Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally.
@@ -314,6 +378,24 @@ export function draftViewToSummary({
}
}
// Process gallery
if (post.embedGallery) {
for (const item of post.embedGallery.items) {
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(item.localRef.path)
if (!exists) {
meta.hasMissingMedia = true
}
images.push({
localPath: item.localRef.path,
altText: item.alt || '',
exists,
})
}
}
// Process videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
@@ -431,54 +513,31 @@ export async function draftToComposerPosts(
media: undefined,
}
// Restore images
// Restore images / gallery. Pick the variant from the restored count so
// we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays
// legacy `images`, >4 promotes to `gallery`). This keeps restore robust
// to drafts whose server slot disagrees with their count - e.g. a draft
// saved in `embedImages` with 5 items would otherwise restore as a
// broken `images` variant the rest of the composer can't grow.
const restoredImages: ComposerImage[] = []
if (post.embedImages && post.embedImages.length > 0) {
const imagePromises = post.embedImages.map(async img => {
const path = loadedMedia.get(img.localRef.path)
if (!path) {
return null
}
let width = 0
let height = 0
try {
const dims = await getImageDim(path)
width = dims.width
height = dims.height
} catch (e) {
logger.warn('Failed to get image dimensions', {
path,
error: e,
})
}
logger.debug('restoring image with localRefPath', {
localRefPath: img.localRef.path,
loadedPath: path,
width,
height,
})
return {
alt: img.alt || '',
// Preserve the original localRefPath for reuse when saving
localRefPath: img.localRef.path,
source: {
id: nanoid(),
path,
width,
height,
mime: 'image/jpeg',
},
} satisfies ComposerImage
})
const images = (await Promise.all(imagePromises)).filter(
(img): img is NonNullable<typeof img> => img !== null,
restoredImages.push(
...(await restoreDraftImages(post.embedImages, loadedMedia)),
)
if (images.length > 0) {
embed.media = {type: 'images', images}
}
}
if (post.embedGallery && post.embedGallery.items.length > 0) {
const galleryImages = post.embedGallery.items.filter(
AppBskyDraftDefs.isDraftEmbedImage,
)
restoredImages.push(
...(await restoreDraftImages(galleryImages, loadedMedia)),
)
}
if (restoredImages.length > 0) {
embed.media =
restoredImages.length <= LEGACY_IMAGES_EMBED_MAX
? {type: 'images', images: restoredImages}
: {type: 'gallery', images: restoredImages}
}
// Restore GIF from external embed
@@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
refs.add(img.localRef.path)
}
}
if (post.embedGallery) {
for (const item of post.embedGallery.items) {
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
refs.add(item.localRef.path)
}
}
if (post.embedVideos) {
for (const vid of post.embedVideos) {
refs.add(vid.localRef.path)
+22 -1
View File
@@ -1,4 +1,4 @@
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api'
import {
useInfiniteQuery,
useMutation,
@@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
}
}
}
// Load gallery
if (post.embedGallery) {
for (const item of post.embedGallery.items) {
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
try {
const url = await storage.loadMediaFromLocal(item.localRef.path)
loadedMedia.set(item.localRef.path, url)
} catch (e) {
logger.error('Failed to load draft gallery image', {
path: item.localRef.path,
safeMessage: e instanceof Error ? e.message : String(e),
})
}
}
}
// Load videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
@@ -226,6 +241,12 @@ export function useDeleteDraftMutation() {
await storage.deleteMediaFromLocal(img.localRef.path)
}
}
if (post.embedGallery) {
for (const item of post.embedGallery.items) {
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
await storage.deleteMediaFromLocal(item.localRef.path)
}
}
if (post.embedVideos) {
for (const vid of post.embedVideos) {
await storage.deleteMediaFromLocal(vid.localRef.path)
+6 -2
View File
@@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
// n > 4 wraps to multiple rows via flexWrap on the gallery container.
const columns = Math.min(images.length, 4)
const side =
images.length === 1
? 250
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
images.length
: (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns
const isOverflow = isMobile && images.length > 2
@@ -245,6 +247,7 @@ const GalleryItem = ({
}}
accessible={true}
accessibilityIgnoresInvertColors
enforceEarlyResizing
cachePolicy="none"
autoplay={false}
contentFit="cover"
@@ -272,6 +275,7 @@ const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
gap: IMAGE_GAP,
marginTop: 16,
},
@@ -3,7 +3,6 @@ import * as MediaLibrary from 'expo-media-library'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {POST_IMG_MAX} from '#/lib/constants'
import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
@@ -35,7 +34,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
}
const img = await openCamera({
aspect: [POST_IMG_MAX.width, POST_IMG_MAX.height],
aspect: [1, 1],
})
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
+67 -25
View File
@@ -16,6 +16,7 @@ import {
postUriToRelativePath,
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
@@ -38,6 +39,11 @@ type ImagesMedia = {
images: ComposerImage[]
}
type GalleryMedia = {
type: 'gallery'
images: ComposerImage[]
}
type VideoMedia = {
type: 'video'
video: VideoState
@@ -59,7 +65,7 @@ type Link = {
export type EmbedDraft = {
// We'll always submit quote and actual media (images, video, gifs) chosen by the user.
quote: Link | undefined
media: ImagesMedia | VideoMedia | GifMedia | undefined
media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined
// This field may end up ignored if we have more important things to display than a link card:
link: Link | undefined
}
@@ -154,7 +160,31 @@ export type ComposerAction =
draftId: string
}
export const MAX_IMAGES = 4
/**
* Threshold for picking between embed variants. <= this count uses the
* legacy `app.bsky.embed.images` shape; > this count promotes to
* `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the
* legacy images embed entirely, this constant (and the variant split it
* gates) should go away.
*/
export const LEGACY_IMAGES_EMBED_MAX = 4
export const MAX_GALLERY_IMAGES = 10
/**
* Picks the embed variant for a set of images. <=4 lands in the legacy
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
* Anything beyond the gallery cap is dropped by the hard slice; callers
* should already have enforced the cap upstream (picker, paste, etc),
* and the reducer logs a warning when the cap is exceeded so the UI
* layer can surface a toast.
*/
function imagesToMediaVariant(
images: ComposerImage[],
): ImagesMedia | GalleryMedia {
return images.length <= LEGACY_IMAGES_EMBED_MAX
? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
: {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
}
export function composerReducer(
state: ComposerState,
@@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
}
const prevMedia = state.embed.media
let nextMedia = prevMedia
const prevCount =
prevMedia?.type === 'images' || prevMedia?.type === 'gallery'
? prevMedia.images.length
: 0
const incomingCount = prevCount + action.images.length
if (incomingCount > MAX_GALLERY_IMAGES) {
// Defense in depth: callers (applyGalleryCap in Composer) should have
// already trimmed and surfaced a toast. The hard slice in
// imagesToMediaVariant still drops the excess so the cap holds.
logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', {
prevCount,
incomingCount,
dropped: incomingCount - MAX_GALLERY_IMAGES,
})
}
if (!prevMedia) {
nextMedia = {
type: 'images',
images: action.images.slice(0, MAX_IMAGES),
}
} else if (prevMedia.type === 'images') {
nextMedia = {
...prevMedia,
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
}
nextMedia = imagesToMediaVariant(action.images)
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
nextMedia = imagesToMediaVariant([
...prevMedia.images,
...action.images,
])
}
return {
...state,
@@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
}
case 'embed_update_image': {
const prevMedia = state.embed.media
if (prevMedia?.type === 'images') {
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const updatedImage = action.image
const nextMedia = {
...prevMedia,
@@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
case 'embed_remove_image': {
const prevMedia = state.embed.media
let nextLabels = state.labels
if (prevMedia?.type === 'images') {
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const removedImage = action.image
let nextMedia: ImagesMedia | undefined = {
...prevMedia,
images: prevMedia.images.filter(img => {
return img.source.id !== removedImage.source.id
}),
}
if (nextMedia.images.length === 0) {
const remainingImages = prevMedia.images.filter(img => {
return img.source.id !== removedImage.source.id
})
let nextMedia: ImagesMedia | GalleryMedia | undefined
if (remainingImages.length === 0) {
nextMedia = undefined
if (!state.embed.link) {
nextLabels = []
}
} else {
// Re-pick the variant so a gallery that shrinks to <=4 demotes
// back to the legacy `app.bsky.embed.images` shape - keeps old
// clients rendering it when possible.
nextMedia = imagesToMediaVariant(remainingImages)
}
return {
...state,
@@ -581,12 +626,9 @@ export function createComposerState({
| AppBskyActorDefs.PostInteractionSettingsPref
| undefined
}): ComposerState {
let media: ImagesMedia | undefined
let media: ImagesMedia | GalleryMedia | undefined
if (initImageUris?.length) {
media = {
type: 'images',
images: createInitialImages(initImageUris),
}
media = imagesToMediaVariant(createInitialImages(initImageUris))
}
let quote: Link | undefined
if (initQuoteUri) {
@@ -16,7 +16,7 @@ import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {POST_IMG_MAX} from '#/lib/constants'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {downloadAndResize} from '#/lib/media/manip'
import {isUriImage} from '#/lib/media/util'
import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
@@ -93,10 +93,7 @@ export function TextInput({
if (isUriImage(feature.uri)) {
const res = await downloadAndResize({
uri: feature.uri,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
...IMAGE_SIZE_CONFIG_POSTS,
timeout: 15e3,
})
+3 -3
View File
@@ -12,7 +12,7 @@ import {
} from '#/lib/hooks/usePermissions'
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
@@ -64,7 +64,7 @@ export function ComposerPrompt() {
Keyboard.dismiss()
}
const selectionCountRemaining = MAX_IMAGES
const selectionCountRemaining = MAX_GALLERY_IMAGES
const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}),
)
@@ -76,7 +76,7 @@ export function ComposerPrompt() {
if (assets.length > 0) {
const imageUris = assets
.filter(asset => asset.mimeType?.startsWith('image/'))
.slice(0, MAX_IMAGES)
.slice(0, MAX_GALLERY_IMAGES)
.map(asset => ({
uri: asset.uri,
width: asset.width,
+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(