Compare commits

...

57 Commits

Author SHA1 Message Date
Eric Bailey 5e0ebcefd2 Resolve chat invites 2026-06-08 15:38:18 -05:00
Eric Bailey e2378f8614 Update GIF type imports 2026-06-06 11:26:14 -05:00
Eric Bailey 54438cd26e Add debug-composer route 2026-06-06 10:03:33 -05:00
Eric Bailey e245d760cf Refine link/embed resolution code 2026-06-06 10:03:33 -05:00
Eric Bailey a930e5832c Clarify handling of async task revs 2026-06-06 10:03:33 -05:00
Eric Bailey b119c36229 Add link resolution failures, non-retryable 2026-06-06 10:03:33 -05:00
Eric Bailey 0c8faaacd6 checkpoint preclassify uri types 2026-06-06 10:03:33 -05:00
Eric Bailey d8ca8525b9 checkpoint uri resolution 2026-06-06 10:03:33 -05:00
Eric Bailey a1eb8f9765 checkopoint full picture, time to wite it up 2026-06-06 10:03:33 -05:00
Eric Bailey a6ad6e8da4 checkpoint unified media 2026-06-06 10:03:33 -05:00
Eric Bailey e3ba1c7c4c checkpoint before media unification 2026-06-06 10:03:33 -05:00
Eric Bailey 7577933378 Fix draft image pres with 4+ images (#10765) 2026-06-06 09:55:39 -05:00
estrattonbailey b9cd7e992a Nightly source-language update 2026-06-05 22:59:53 +00:00
surfdude29 a253e877fc Add plural formatting for error toast (#10756) 2026-06-05 17:57:57 -05:00
Eric Bailey 11dd259716 [Drafts] Always write embedGallery for image embeds (#10761)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:56:08 -05:00
Eric Bailey 0cc6d49388 Android share intent: logging + temp file cleanup (#10762)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:35:21 -05:00
Samuel Newman 5eeb80fe96 Fix Android crash when sharing unreadable media into the app (#10674) 2026-06-06 01:03:29 +03:00
DS Boyce a675cbacb6 Link to owner's profile from link embed (#10759) 2026-06-05 14:26:30 -07:00
DS Boyce 1d5fb89c80 Improve error messages for LeaveConvoPrompt (#10760) 2026-06-05 14:19:48 -07:00
Samuel Newman ac63ee7739 Remove useWindowDimensions from ProfileBadges (#10738) 2026-06-05 16:17:05 -05:00
Samuel Newman a5338090cb Let specified facets take priority in feed descriptions (#10740) 2026-06-05 16:13:26 -05:00
Samuel Newman fb8316fd85 Fix external card long press on web (#10739) 2026-06-05 16:04:21 -05:00
Samuel Newman b236f274c3 [Chat] Enforce limits for group size (#10753) 2026-06-05 23:17:29 +03:00
Samuel Newman 2e9bfd363c [Chat] Put pointer_events_none on chat bubbles in ChatListItem (#10752) 2026-06-05 13:09:55 -07:00
Samuel Newman 32c0c0ec83 "Follows You" string dedupe (#10751) 2026-06-05 13:08:39 -07:00
Samuel Newman 6d468c8ca4 [CI] Remove Wandalen/wretry.action, call pnpm install directly (#10745) 2026-06-05 22:49:02 +03:00
Samuel Newman a5aa985b33 Fix feed scroll reset on device rotation (#10737) 2026-06-05 22:48:34 +03:00
Eric Bailey fd8df7e201 Fire embed:standardSite:view from feed viewability, not embed mount (#10736)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:50:16 -05:00
Samuel Newman f8aae4a192 [Chat] Disable font scaling on fixed-height chat invite cards (#10744)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:18:27 +03:00
Samuel Newman 67ed59fbcd Align group invite settings with eligibility logic (#10748)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:10:14 +03:00
Samuel Newman 7d4d7642fd [Chat] Sync single-convo cache on chat lock firehose events (#10746)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:39:23 -07:00
Samuel Newman f81bb6f494 [Chat] Fix navigation stacking on messages links in split view (#10741)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:38:48 -07:00
Samuel Newman 3cdc0604ca [Chat] Fix stale reactions dialog after removing a reaction (#10743)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:38:23 -07:00
Samuel Newman d3f073b3d2 [Chat] Request screen tweaks (#10747) 2026-06-05 06:35:06 -07:00
estrattonbailey 96e321dfd9 Nightly source-language update 2026-06-05 02:21:15 +00:00
Spence Pope 68da797180 Add OTA fallback for gallery embed (#10734)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 21:19:35 -05:00
Eric Bailey 2f61c0a7b4 Gate StandardSite subscribe button custom theme on AAA contrast (#10735)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:51:34 -05:00
Samuel Newman f84c85aac6 Use Reanimated layout animations for Android composer (#8546) 2026-06-05 00:37:42 +03:00
Spence Pope 144ba30ea6 Add gallery embed type support (display + compose) (#10707) 2026-06-04 17:22:40 -04:00
DS Boyce de926d1838 Show a banner when there are incoming chat requests (#10498)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-04 14:22:29 -07:00
DS Boyce 45bd5b45e8 Reduce stale time for join link preview queries (#10733) 2026-06-04 14:11:50 -07: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
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
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
Samuel Newman 153772b760 expo-image tweaks (#10710) 2026-06-04 01:03:11 +03:00
172 changed files with 7646 additions and 1563 deletions
+2
View File
@@ -20,6 +20,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -37,6 +38,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+2 -10
View File
@@ -57,11 +57,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
@@ -99,11 +95,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Run tests
@@ -26,11 +26,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
+2 -6
View File
@@ -34,12 +34,8 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
- name: Verify pnpm-lock.yaml
run: |
+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
-29
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
@@ -274,11 +261,6 @@
"count": 1
}
},
"src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -811,14 +793,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
@@ -1372,9 +1346,6 @@
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 5
}
@@ -6,6 +6,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -13,6 +14,8 @@ import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents"
enum class AttachmentType {
IMAGE,
VIDEO,
@@ -119,17 +122,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris: List<Uri>,
text: String?,
) {
var allParams = ""
// Some URIs we receive may be unreadable (revoked permission, deleted file,
// a provider that rejects the read). Skip those rather than crashing the
// whole app, since this runs synchronously on the module init path.
val allParams =
uris
.mapNotNull { uri -> getImageInfo(uri) }
.joinToString(",") { info -> buildUriData(info) }
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
if (index < uris.count() - 1) {
allParams = "$allParams,"
}
}
if (allParams.isEmpty()) return
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -158,12 +159,30 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
val file = createFile(extension)
val out = FileOutputStream(file)
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Bail rather than crashing the whole
// app, since this runs synchronously on the module init path.
try {
FileOutputStream(file).use { out ->
val input =
appContext.currentActivity?.contentResolver?.openInputStream(uri)
?: run {
file.delete()
return
}
input.use { it.copyTo(out) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy shared video to cache", e)
file.delete()
return
}
val info = getVideoInfo(uri) ?: return
val info =
getVideoInfo(uri) ?: run {
file.delete()
return
}
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -176,15 +195,29 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
private fun getImageInfo(uri: Uri): Map<String, Any>? {
val bitmap =
try {
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
} catch (e: Exception) {
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Skip this image rather than crash.
Log.w(TAG, "Failed to read shared image", e)
return null
} ?: return null
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = createFile("jpeg")
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to write shared image to cache", e)
file.delete()
return null
}
return mapOf(
"width" to bitmap.width,
@@ -195,10 +228,19 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
val width: Int?
val height: Int?
try {
retriever.setDataSource(appContext.currentActivity, uri)
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} catch (e: Exception) {
// The URI may be unreadable or not a valid media source. Skip rather than crash.
Log.w(TAG, "Failed to read shared video metadata", e)
return null
} finally {
retriever.release()
}
if (width == null || height == null) {
return null
+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
+6
View File
@@ -55,6 +55,7 @@ import {
import {useCloseAllActiveElements} from '#/state/util'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugComposerScreen} from '#/view/screens/DebugComposer'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="DebugComposer"
getComponent={() => DebugComposerScreen}
options={{title: title(msg`Composer V2`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
+7 -1
View File
@@ -9,7 +9,12 @@ import {
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {themes} from '#/alf/themes'
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
import {
contrastRatio,
darken,
lighten,
rgbToHex,
} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -26,6 +31,7 @@ export const utils = {
rgbToHex,
lighten,
darken,
contrastRatio,
}
export type Alf = {
+37 -1
View File
@@ -1,4 +1,10 @@
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
import {
contrastRatio,
darken,
hexToRgb,
lighten,
rgbToHex,
} from './colorGeneration'
describe('hexToRgb', () => {
it('parses 6-digit hex', () => {
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz')
})
})
describe('contrastRatio', () => {
it('returns 21 for black on white', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
})
it('returns 1 for identical colors', () => {
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
})
it('is symmetric regardless of argument order', () => {
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
contrastRatio('#fedcba', '#123456')!,
5,
)
})
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
})
it('returns null for invalid hex input', () => {
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
})
})
+42
View File
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}`
}
/**
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
* either argument is not a valid hex color.
*
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
*/
export function contrastRatio(hexA: string, hexB: string): number | null {
const rgbA = hexToRgb(hexA)
const rgbB = hexToRgb(hexB)
if (!rgbA || !rgbB) return null
const luminanceA = relativeLuminance(rgbA)
const luminanceB = relativeLuminance(rgbB)
const lighter = Math.max(luminanceA, luminanceB)
const darker = Math.min(luminanceA, luminanceB)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
* to 1 (white).
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance({
r,
g,
b,
}: {
r: number
g: number
b: number
}): number {
const toLinear = (channel: number) => {
const normalized = channel / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
}
function rgbToHsl(
r: number,
g: number,
+19
View File
@@ -0,0 +1,19 @@
import {useEffect, useState} from 'react'
import {Dimensions} from 'react-native'
/**
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
* whenever the screen size changes
*/
export function useNativeFontScale() {
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
useEffect(() => {
const sub = Dimensions.addEventListener('change', evt => {
setFontScale(evt.window.fontScale)
})
return () => sub.remove()
}, [])
return fontScale
}
+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']
@@ -0,0 +1,31 @@
import {createContext, useContext} from 'react'
import {type createThreadStore} from '#/components/ComposerV2/store'
export type ThreadStore = ReturnType<typeof createThreadStore>
const ThreadStoreContext = createContext<ThreadStore | null>(null)
export function ThreadStoreProvider({
store,
children,
}: {
store: ThreadStore
children: React.ReactNode
}) {
return (
<ThreadStoreContext.Provider value={store}>
{children}
</ThreadStoreContext.Provider>
)
}
export function useThreadStore(): ThreadStore {
const store = useContext(ThreadStoreContext)
if (!store) {
throw new Error(
'useThreadStore must be used inside a <ThreadStoreProvider>',
)
}
return store
}
+8
View File
@@ -0,0 +1,8 @@
export {
type ThreadStore,
ThreadStoreProvider,
useThreadStore,
} from '#/components/ComposerV2/hooks/ThreadStoreContext'
export {useThreadPost} from '#/components/ComposerV2/hooks/useThreadPost'
export {useThreadPostRichText} from '#/components/ComposerV2/hooks/useThreadPostRichText'
export {useThreadState} from '#/components/ComposerV2/hooks/useThreadState'
@@ -0,0 +1,18 @@
import {useSyncExternalStore} from 'react'
import {useThreadStore} from '#/components/ComposerV2/hooks/ThreadStoreContext'
import {type ThreadPost} from '#/components/ComposerV2/store/types'
/**
* Subscribe to a single post by id. Returns undefined if no such post
* exists. Rerenders only when this specific post's reference changes -
* unrelated post mutations don't propagate here because the store's
* actions spread `{...post, ...}` only on the touched post.
*/
export function useThreadPost(postId: string): ThreadPost | undefined {
const store = useThreadStore()
return useSyncExternalStore(
store.subscribe,
() => store.getState().posts[postId],
)
}
@@ -0,0 +1,30 @@
import {useMemo} from 'react'
import {RichText} from '@atproto/api'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {useThreadPost} from '#/components/ComposerV2/hooks/useThreadPost'
/**
* Derived RichText for a post's text. Recomputes only when the underlying
* `text` changes (not on unrelated mutations like alt-text or upload
* status). detectFacetsWithoutResolution is regex-only, so this is cheap
* to run per-keystroke for typical post lengths.
*
* `shortenedGraphemeLength` matches the value the existing draft adapter
* stores - the count after URL shortening, used for the post char limit.
*/
export function useThreadPostRichText(postId: string): {
richtext: RichText
shortenedGraphemeLength: number
} {
const post = useThreadPost(postId)
const text = post?.text ?? ''
return useMemo(() => {
const richtext = new RichText({text})
richtext.detectFacetsWithoutResolution()
return {
richtext,
shortenedGraphemeLength: shortenLinks(richtext).graphemeLength,
}
}, [text])
}
@@ -0,0 +1,13 @@
import {useSyncExternalStore} from 'react'
import {useThreadStore} from '#/components/ComposerV2/hooks/ThreadStoreContext'
import {type ThreadState} from '#/components/ComposerV2/store/types'
/**
* Subscribe to the full thread state. Rerenders whenever any post changes.
* For finer-grained subscriptions use `useThreadPost(id)`.
*/
export function useThreadState(): ThreadState {
const store = useThreadStore()
return useSyncExternalStore(store.subscribe, store.getState)
}
@@ -0,0 +1,304 @@
import {describe, expect, test} from '@jest/globals'
import * as reducers from '#/components/ComposerV2/lib/reducers'
import {
type ComposerState,
type GifItem,
type VideoItem,
} from '#/components/ComposerV2/lib/types'
function makeState(): ComposerState {
return reducers.createInitialState({rootPostId: 'p1'})
}
function makeVideo(id = 'v1'): VideoItem {
return {
id,
uri: 'file:///video.mp4',
width: 100,
height: 100,
altText: '',
mimeType: 'video/mp4',
localRefPath: `video:video/mp4:${id}`,
captions: [],
upload: {state: 'pending'},
}
}
function makeGif(id = 'g1'): GifItem {
return {
id,
altText: '',
// The Gif type from tenor is large; for reducer tests the store doesn't
// inspect any of those fields, so we cast a minimal shape.
gif: {url: 'https://example.com/gif'} as GifItem['gif'],
}
}
describe('createInitialState', () => {
test('starts with one empty post', () => {
const s = reducers.createInitialState({rootPostId: 'root'})
expect(s.posts).toHaveLength(1)
expect(s.posts[0].id).toBe('root')
expect(s.posts[0].text).toBe('')
expect(s.isDirty).toBe(false)
})
test('captures replyTo and draftId when provided', () => {
const s = reducers.createInitialState({
rootPostId: 'root',
replyTo: {uri: 'at://x', cid: 'c', authorDid: 'did:plc:a'},
draftId: 'draft-1',
})
expect(s.replyTo?.uri).toBe('at://x')
expect(s.draftId).toBe('draft-1')
})
})
describe('updateText', () => {
test('sets text on the matching post and marks dirty', () => {
const s = reducers.updateText(makeState(), {postId: 'p1', text: 'hello'})
expect(s.posts[0].text).toBe('hello')
expect(s.isDirty).toBe(true)
})
test('returns identical reference when text unchanged', () => {
const s1 = makeState()
const s2 = reducers.updateText(s1, {postId: 'p1', text: ''})
expect(s2).toBe(s1)
})
test('leaves state unchanged when post id is unknown', () => {
const s1 = makeState()
const s2 = reducers.updateText(s1, {postId: 'nope', text: 'hi'})
expect(s2).toBe(s1)
})
})
describe('appendPost / insertPostAfter / removePost', () => {
test('appendPost adds a post at the end', () => {
const s = reducers.appendPost(makeState(), {id: 'p2'})
expect(s.posts.map(p => p.id)).toEqual(['p1', 'p2'])
expect(s.isDirty).toBe(true)
})
test('insertPostAfter places a post immediately after the target', () => {
let s = reducers.appendPost(makeState(), {id: 'p2'})
s = reducers.appendPost(s, {id: 'p3'})
s = reducers.insertPostAfter(s, {afterId: 'p1', id: 'p1b'})
expect(s.posts.map(p => p.id)).toEqual(['p1', 'p1b', 'p2', 'p3'])
})
test('insertPostAfter is a no-op for an unknown afterId', () => {
const s1 = makeState()
const s2 = reducers.insertPostAfter(s1, {afterId: 'nope', id: 'x'})
expect(s2).toBe(s1)
})
test('removePost removes the matching post', () => {
let s = reducers.appendPost(makeState(), {id: 'p2'})
s = reducers.appendPost(s, {id: 'p3'})
s = reducers.removePost(s, {postId: 'p2'})
expect(s.posts.map(p => p.id)).toEqual(['p1', 'p3'])
})
test('removePost is a no-op for an unknown post id', () => {
const s1 = reducers.appendPost(makeState(), {id: 'p2'})
const s2 = reducers.removePost(s1, {postId: 'nope'})
expect(s2).toBe(s1)
})
test('removePost refuses to remove the last remaining post', () => {
const s1 = makeState()
const s2 = reducers.removePost(s1, {postId: 'p1'})
expect(s2).toBe(s1)
expect(s2.posts).toHaveLength(1)
})
})
describe('image media', () => {
test('addImages appends images with pending upload status', () => {
const s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{
id: 'i1',
uri: 'file:///a.jpg',
width: 1,
height: 1,
localRefPath: 'image:i1',
},
],
})
expect(s.posts[0].media?.kind).toBe('images')
if (s.posts[0].media?.kind !== 'images') return
expect(s.posts[0].media.items).toHaveLength(1)
expect(s.posts[0].media.items[0].upload.state).toBe('pending')
})
test('addImages a second time appends rather than replacing', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
s = reducers.addImages(s, {
postId: 'p1',
images: [
{id: 'i2', uri: 'b', width: 1, height: 1, localRefPath: 'image:i2'},
],
})
if (s.posts[0].media?.kind !== 'images') throw new Error('expected images')
expect(s.posts[0].media.items.map(i => i.id)).toEqual(['i1', 'i2'])
})
test('removeImage removes the matching image and clears media when last one is gone', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
s = reducers.removeImage(s, {postId: 'p1', imageId: 'i1'})
expect(s.posts[0].media).toBeUndefined()
})
test('updateImageAltText updates only the matching image', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
{id: 'i2', uri: 'b', width: 1, height: 1, localRefPath: 'image:i2'},
],
})
s = reducers.updateImageAltText(s, {
postId: 'p1',
imageId: 'i2',
altText: 'hello',
})
if (s.posts[0].media?.kind !== 'images') throw new Error('expected images')
expect(s.posts[0].media.items[0].altText).toBe('')
expect(s.posts[0].media.items[1].altText).toBe('hello')
})
})
describe('video and gif media', () => {
test('setVideo replaces any existing media with the video', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
s = reducers.setVideo(s, {postId: 'p1', video: makeVideo()})
expect(s.posts[0].media?.kind).toBe('video')
})
test('removeVideo only removes when current media is a video', () => {
let s = reducers.setGif(makeState(), {postId: 'p1', gif: makeGif()})
s = reducers.removeVideo(s, {postId: 'p1'})
expect(s.posts[0].media?.kind).toBe('gif')
})
test('setGif replaces any existing media with a gif', () => {
let s = reducers.setVideo(makeState(), {postId: 'p1', video: makeVideo()})
s = reducers.setGif(s, {postId: 'p1', gif: makeGif()})
expect(s.posts[0].media?.kind).toBe('gif')
})
})
describe('external link and quote', () => {
test('setExternal stores a link card and removeExternal clears it', () => {
let s = reducers.setExternal(makeState(), {
postId: 'p1',
external: {uri: 'https://example.com'},
})
expect(s.posts[0].external?.uri).toBe('https://example.com')
s = reducers.removeExternal(s, {postId: 'p1'})
expect(s.posts[0].external).toBeUndefined()
})
test('setQuote stores a quote and removeQuote clears it', () => {
let s = reducers.setQuote(makeState(), {
postId: 'p1',
quote: {uri: 'at://x', cid: 'c'},
})
expect(s.posts[0].quote?.uri).toBe('at://x')
s = reducers.removeQuote(s, {postId: 'p1'})
expect(s.posts[0].quote).toBeUndefined()
})
})
describe('updateLabels', () => {
test('replaces the labels array on the matching post', () => {
const s = reducers.updateLabels(makeState(), {
postId: 'p1',
labels: ['sexual', 'graphic-media'],
})
expect(s.posts[0].labels).toEqual(['sexual', 'graphic-media'])
})
})
describe('setUploadStatus', () => {
test('updates the upload field on the matching image', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
s = reducers.setUploadStatus(s, {
mediaId: 'i1',
status: {state: 'uploading', progress: 0.5},
})
if (s.posts[0].media?.kind !== 'images') throw new Error('expected images')
expect(s.posts[0].media.items[0].upload).toEqual({
state: 'uploading',
progress: 0.5,
})
})
test('updates the upload field on a video', () => {
let s = reducers.setVideo(makeState(), {postId: 'p1', video: makeVideo()})
s = reducers.setUploadStatus(s, {
mediaId: 'v1',
status: {state: 'failed', error: 'boom'},
})
if (s.posts[0].media?.kind !== 'video') throw new Error('expected video')
expect(s.posts[0].media.item.upload).toEqual({
state: 'failed',
error: 'boom',
})
})
test('returns identical state when the media id is unknown', () => {
const s1 = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
const s2 = reducers.setUploadStatus(s1, {
mediaId: 'gone',
status: {state: 'uploading', progress: 0.5},
})
expect(s2).toBe(s1)
})
test('does not mark the state dirty (background upload progress is not a user edit)', () => {
let s = reducers.addImages(makeState(), {
postId: 'p1',
images: [
{id: 'i1', uri: 'a', width: 1, height: 1, localRefPath: 'image:i1'},
],
})
// Reset dirty so we're isolating setUploadStatus' behavior.
s = {...s, isDirty: false}
const s2 = reducers.setUploadStatus(s, {
mediaId: 'i1',
status: {state: 'uploading', progress: 0.25},
})
expect(s2.isDirty).toBe(false)
})
})
@@ -0,0 +1,111 @@
import {beforeEach, describe, expect, jest, test} from '@jest/globals'
import {type AtpAgent} from '@atproto/api'
import {createComposerStore} from '#/components/ComposerV2/lib/store'
// Deterministic ids: 'id-1', 'id-2', ... so tests can assert on them.
function makeIdGenerator() {
let i = 0
return () => `id-${++i}`
}
// We never call the agent in these tests; just satisfy the type.
const agent = {} as AtpAgent
describe('createComposerStore - basics', () => {
test('seeds with one empty post', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const state = store.getState()
expect(state.posts).toHaveLength(1)
expect(state.posts[0].id).toBe('id-1')
})
test('subscribers are notified on state change', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const fn = jest.fn()
const unsubscribe = store.subscribe(fn)
store.actions.updateText('id-1', 'hi')
expect(fn).toHaveBeenCalledTimes(1)
unsubscribe()
store.actions.updateText('id-1', 'hi again')
expect(fn).toHaveBeenCalledTimes(1)
})
test('subscribers are not notified when state is unchanged', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const fn = jest.fn()
store.subscribe(fn)
// Same text as initial empty string -> no-op
store.actions.updateText('id-1', '')
expect(fn).not.toHaveBeenCalled()
})
test('appendPost returns the new post id and appends it', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const newId = store.actions.appendPost()
expect(newId).toBe('id-2')
expect(store.getState().posts.map(p => p.id)).toEqual(['id-1', 'id-2'])
})
test('removePost works and refuses to remove the last post', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
store.actions.appendPost()
store.actions.removePost('id-2')
expect(store.getState().posts.map(p => p.id)).toEqual(['id-1'])
// Last post is sticky
store.actions.removePost('id-1')
expect(store.getState().posts.map(p => p.id)).toEqual(['id-1'])
})
test('destroy stops applying actions and clears subscribers', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const fn = jest.fn()
store.subscribe(fn)
store.destroy()
store.actions.updateText('id-1', 'after destroy')
expect(fn).not.toHaveBeenCalled()
expect(store.getState().posts[0].text).toBe('')
})
})
describe('createComposerStore - simulated image uploads', () => {
beforeEach(() => {
jest.useFakeTimers()
})
test('addImages drives upload state from pending -> uploading -> uploaded', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
// Each addImages call uses 2 ids (one for the image id, one for its localRefPath),
// so the image id is 'id-2' here.
const ids = store.actions.addImages('id-1', [
{uri: 'file:///a.jpg', width: 10, height: 10},
])
expect(ids).toEqual(['id-2'])
const get = () => {
const post = store.getState().posts[0]
if (post.media?.kind !== 'images') throw new Error('expected images')
return post.media.items[0].upload
}
expect(get().state).toBe('pending')
jest.advanceTimersByTime(100)
expect(get().state).toBe('uploading')
// Run all remaining timers to completion
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('removing an image while uploading does not throw and leaves state stable', () => {
const store = createComposerStore({agent, idGenerator: makeIdGenerator()})
const [imageId] = store.actions.addImages('id-1', [
{uri: 'file:///a.jpg', width: 10, height: 10},
])
jest.advanceTimersByTime(100)
store.actions.removeImage('id-1', imageId)
// Drain the rest of the simulated upload - setUploadStatus should silently
// no-op since the media is gone.
expect(() => jest.runAllTimers()).not.toThrow()
expect(store.getState().posts[0].media).toBeUndefined()
})
})
+320
View File
@@ -0,0 +1,320 @@
/**
* Pure reducer functions for the ComposerV2 store.
*
* Each function takes the current state plus arguments and returns the next
* state. They never throw on missing IDs; they return state unchanged so the
* store layer doesn't have to coordinate "did anything happen" with async
* sources like upload workers that may race a removePost.
*
* IDs are passed in (not generated here) so reducers stay deterministic and
* tests don't need to mock id generation.
*/
import {
type ComposerState,
type ExternalEmbed,
type GifItem,
type ImageItem,
type NewImageInput,
type PostDraft,
type Quote,
type UploadStatus,
type VideoItem,
} from './types'
export function createEmptyPost(id: string): PostDraft {
return {
id,
text: '',
langs: [],
labels: [],
media: undefined,
external: undefined,
quote: undefined,
}
}
export function createInitialState(args: {
rootPostId: string
replyTo?: ComposerState['replyTo']
draftId?: string
}): ComposerState {
const root = createEmptyPost(args.rootPostId)
return {
posts: [root],
replyTo: args.replyTo,
draftId: args.draftId,
isDirty: false,
}
}
function mapPost(
state: ComposerState,
postId: string,
fn: (post: PostDraft) => PostDraft,
): ComposerState {
let changed = false
const posts = state.posts.map(post => {
if (post.id !== postId) return post
const next = fn(post)
if (next !== post) changed = true
return next
})
if (!changed) return state
return markDirty({...state, posts})
}
function markDirty(state: ComposerState): ComposerState {
if (state.isDirty) return state
return {...state, isDirty: true}
}
export function updateText(
state: ComposerState,
args: {postId: string; text: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.text === args.text) return post
return {...post, text: args.text}
})
}
export function updateLangs(
state: ComposerState,
args: {postId: string; langs: string[]},
): ComposerState {
return mapPost(state, args.postId, post => ({...post, langs: args.langs}))
}
export function updateLabels(
state: ComposerState,
args: {postId: string; labels: string[]},
): ComposerState {
return mapPost(state, args.postId, post => ({...post, labels: args.labels}))
}
export function appendPost(
state: ComposerState,
args: {id: string},
): ComposerState {
const newPost = createEmptyPost(args.id)
return markDirty({...state, posts: [...state.posts, newPost]})
}
export function insertPostAfter(
state: ComposerState,
args: {afterId: string; id: string},
): ComposerState {
const idx = state.posts.findIndex(p => p.id === args.afterId)
if (idx === -1) return state
const newPost = createEmptyPost(args.id)
const posts = [
...state.posts.slice(0, idx + 1),
newPost,
...state.posts.slice(idx + 1),
]
return markDirty({...state, posts})
}
export function removePost(
state: ComposerState,
args: {postId: string},
): ComposerState {
// Never allow removing the last post; the composer always has at least one.
if (state.posts.length <= 1) return state
const posts = state.posts.filter(p => p.id !== args.postId)
if (posts.length === state.posts.length) return state
return markDirty({...state, posts})
}
export function addImages(
state: ComposerState,
args: {postId: string; images: Array<NewImageInput & {id: string; localRefPath: string}>},
): ComposerState {
return mapPost(state, args.postId, post => {
// Adding images replaces any existing video or gif since media is exclusive.
const existing =
post.media?.kind === 'images' ? post.media.items : ([] as ImageItem[])
const next: ImageItem[] = [
...existing,
...args.images.map(img => ({
id: img.id,
uri: img.uri,
width: img.width,
height: img.height,
altText: img.altText ?? '',
localRefPath: img.localRefPath,
upload: {state: 'pending'} as UploadStatus,
})),
]
return {...post, media: {kind: 'images', items: next}}
})
}
export function removeImage(
state: ComposerState,
args: {postId: string; imageId: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'images') return post
const items = post.media.items.filter(i => i.id !== args.imageId)
if (items.length === post.media.items.length) return post
if (items.length === 0) return {...post, media: undefined}
return {...post, media: {kind: 'images', items}}
})
}
export function updateImageAltText(
state: ComposerState,
args: {postId: string; imageId: string; altText: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'images') return post
const items = post.media.items.map(i =>
i.id === args.imageId ? {...i, altText: args.altText} : i,
)
return {...post, media: {kind: 'images', items}}
})
}
export function setVideo(
state: ComposerState,
args: {postId: string; video: VideoItem},
): ComposerState {
return mapPost(state, args.postId, post => ({
...post,
media: {kind: 'video', item: args.video},
}))
}
export function removeVideo(
state: ComposerState,
args: {postId: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'video') return post
return {...post, media: undefined}
})
}
export function updateVideoAltText(
state: ComposerState,
args: {postId: string; altText: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'video') return post
return {
...post,
media: {kind: 'video', item: {...post.media.item, altText: args.altText}},
}
})
}
export function setGif(
state: ComposerState,
args: {postId: string; gif: GifItem},
): ComposerState {
return mapPost(state, args.postId, post => ({
...post,
media: {kind: 'gif', item: args.gif},
}))
}
export function removeGif(
state: ComposerState,
args: {postId: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'gif') return post
return {...post, media: undefined}
})
}
export function updateGifAltText(
state: ComposerState,
args: {postId: string; altText: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (post.media?.kind !== 'gif') return post
return {
...post,
media: {kind: 'gif', item: {...post.media.item, altText: args.altText}},
}
})
}
export function setExternal(
state: ComposerState,
args: {postId: string; external: ExternalEmbed},
): ComposerState {
return mapPost(state, args.postId, post => ({...post, external: args.external}))
}
export function removeExternal(
state: ComposerState,
args: {postId: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (!post.external) return post
return {...post, external: undefined}
})
}
export function setQuote(
state: ComposerState,
args: {postId: string; quote: Quote},
): ComposerState {
return mapPost(state, args.postId, post => ({...post, quote: args.quote}))
}
export function removeQuote(
state: ComposerState,
args: {postId: string},
): ComposerState {
return mapPost(state, args.postId, post => {
if (!post.quote) return post
return {...post, quote: undefined}
})
}
/**
* Update the upload status of a media item. Searches every post since the
* upload worker only knows the media id, not which post it lives on (and posts
* could have been reordered or removed in the meantime).
*
* Returns state unchanged if the media item no longer exists - this is the
* expected case when an upload completes after the user removed the media.
*/
export function setUploadStatus(
state: ComposerState,
args: {mediaId: string; status: UploadStatus},
): ComposerState {
let changed = false
const posts = state.posts.map(post => {
if (!post.media) return post
if (post.media.kind === 'images') {
let itemChanged = false
const items = post.media.items.map(item => {
if (item.id !== args.mediaId) return item
itemChanged = true
return {...item, upload: args.status}
})
if (!itemChanged) return post
changed = true
return {...post, media: {kind: 'images' as const, items}}
}
if (post.media.kind === 'video' && post.media.item.id === args.mediaId) {
changed = true
return {
...post,
media: {
kind: 'video' as const,
item: {...post.media.item, upload: args.status},
},
}
}
return post
})
if (!changed) return state
// Upload progress shouldn't itself mark dirty - it's not a user edit.
return {...state, posts}
}
+242
View File
@@ -0,0 +1,242 @@
/**
* The ComposerV2 store: a small subscribable container around ComposerState.
*
* Provider-owned (one instance per composer session) so tests can construct a
* fresh store without any React tree. Pure reducers from ./reducers do all the
* state transitions; the store factory wraps them with id generation, listener
* notification, and side-effects like kicking off background uploads.
*
* The `agent` is held in a private closure for the upload worker. State held
* by the store is plain data so that snapshot/restore (for OS-resume) and
* thin-adapter export to the saved-draft format are trivial.
*/
import {type AtpAgent} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import * as reducers from './reducers'
import {startImageUpload, startVideoUpload} from './uploads'
import {
type ComposerState,
type ExternalEmbed,
type GifItem,
type NewImageInput,
type NewVideoInput,
type Quote,
type ReplyTo,
type UploadStatus,
} from './types'
type Listener = () => void
export type ComposerActions = {
updateText(postId: string, text: string): void
updateLangs(postId: string, langs: string[]): void
updateLabels(postId: string, labels: string[]): void
appendPost(): string
insertPostAfter(afterId: string): string
removePost(postId: string): void
setActivePost(postId: string): void
addImages(postId: string, images: NewImageInput[]): string[]
removeImage(postId: string, imageId: string): void
updateImageAltText(postId: string, imageId: string, altText: string): void
setVideo(postId: string, video: NewVideoInput): string
removeVideo(postId: string): void
updateVideoAltText(postId: string, altText: string): void
setGif(postId: string, gif: Omit<GifItem, 'id'>): string
removeGif(postId: string): void
updateGifAltText(postId: string, altText: string): void
setExternal(postId: string, external: ExternalEmbed): void
removeExternal(postId: string): void
setQuote(postId: string, quote: Quote): void
removeQuote(postId: string): void
/** Called by the upload worker when an image or video upload progresses. */
setUploadStatus(mediaId: string, status: UploadStatus): void
}
export type ComposerStore = {
getState(): ComposerState
subscribe(listener: Listener): () => void
actions: ComposerActions
destroy(): void
}
export type CreateComposerStoreOptions = {
agent: AtpAgent
replyTo?: ReplyTo
draftId?: string
/** Override id generation; useful for deterministic tests. */
idGenerator?: () => string
}
export function createComposerStore(
options: CreateComposerStoreOptions,
): ComposerStore {
const newId = options.idGenerator ?? nanoid
// The agent is captured by the upload worker via getAgent(); held here so
// future actions (publish, draft save) can reach it without re-passing.
const agent = options.agent
let state: ComposerState = reducers.createInitialState({
rootPostId: newId(),
replyTo: options.replyTo,
draftId: options.draftId,
})
const listeners = new Set<Listener>()
let destroyed = false
function getState() {
return state
}
function subscribe(listener: Listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function set(next: ComposerState) {
if (next === state) return
state = next
for (const listener of listeners) listener()
}
function apply<A>(
reducer: (s: ComposerState, args: A) => ComposerState,
args: A,
) {
if (destroyed) return
set(reducer(state, args))
}
const actions: ComposerActions = {
updateText(postId, text) {
apply(reducers.updateText, {postId, text})
},
updateLangs(postId, langs) {
apply(reducers.updateLangs, {postId, langs})
},
updateLabels(postId, labels) {
apply(reducers.updateLabels, {postId, labels})
},
appendPost() {
const id = newId()
apply(reducers.appendPost, {id})
return id
},
insertPostAfter(afterId) {
const id = newId()
apply(reducers.insertPostAfter, {afterId, id})
return id
},
removePost(postId) {
apply(reducers.removePost, {postId})
},
setActivePost(postId) {
apply(reducers.setActivePost, {postId})
},
addImages(postId, images) {
const withIds = images.map(img => ({
...img,
id: newId(),
localRefPath: `image:${newId()}`,
}))
apply(reducers.addImages, {postId, images: withIds})
for (const img of withIds) {
startImageUpload({
mediaId: img.id,
uri: img.uri,
agent,
setUploadStatus: actions.setUploadStatus,
isAlive: () => !destroyed,
})
}
return withIds.map(i => i.id)
},
removeImage(postId, imageId) {
apply(reducers.removeImage, {postId, imageId})
},
updateImageAltText(postId, imageId, altText) {
apply(reducers.updateImageAltText, {postId, imageId, altText})
},
setVideo(postId, video) {
const id = newId()
const localRefPath = `video:${video.mimeType}:${newId()}`
apply(reducers.setVideo, {
postId,
video: {
id,
uri: video.uri,
width: video.width,
height: video.height,
mimeType: video.mimeType,
altText: video.altText ?? '',
localRefPath,
captions: [],
upload: {state: 'pending'},
},
})
startVideoUpload({
mediaId: id,
uri: video.uri,
agent,
setUploadStatus: actions.setUploadStatus,
isAlive: () => !destroyed,
})
return id
},
removeVideo(postId) {
apply(reducers.removeVideo, {postId})
},
updateVideoAltText(postId, altText) {
apply(reducers.updateVideoAltText, {postId, altText})
},
setGif(postId, gif) {
const id = newId()
apply(reducers.setGif, {postId, gif: {id, ...gif}})
return id
},
removeGif(postId) {
apply(reducers.removeGif, {postId})
},
updateGifAltText(postId, altText) {
apply(reducers.updateGifAltText, {postId, altText})
},
setExternal(postId, external) {
apply(reducers.setExternal, {postId, external})
},
removeExternal(postId) {
apply(reducers.removeExternal, {postId})
},
setQuote(postId, quote) {
apply(reducers.setQuote, {postId, quote})
},
removeQuote(postId) {
apply(reducers.removeQuote, {postId})
},
setUploadStatus(mediaId, status) {
apply(reducers.setUploadStatus, {mediaId, status})
},
}
function destroy() {
destroyed = true
listeners.clear()
}
return {getState, subscribe, actions, destroy}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Types for the ComposerV2 store.
*
* The store holds the in-progress state of a thread the user is composing:
* one or more posts, each with text, optional media (images, video, or a gif),
* an optional external link card, an optional quote, and labels. Which post
* has UI focus is purely a view-layer concern and lives outside the store.
*
* Media uploads are first-class state. A background worker writes upload
* progress directly into the relevant media item via setUploadStatus, so
* components subscribed to that slice rerender without coordination.
*/
import {type AppBskyFeedDefs, type BlobRef} from '@atproto/api'
import {type Gif} from '#/state/queries/tenor'
export type UploadStatus =
| {state: 'pending'}
| {state: 'uploading'; progress: number}
| {state: 'uploaded'; blob: BlobRef}
| {state: 'failed'; error: string}
export type ImageItem = {
id: string
uri: string
width: number
height: number
altText: string
/** Stable path used to round-trip through saved drafts without re-copying bytes. */
localRefPath: string
upload: UploadStatus
}
export type VideoItem = {
id: string
uri: string
width: number
height: number
altText: string
mimeType: string
localRefPath: string
captions: Array<{lang: string; content: string}>
upload: UploadStatus
}
export type GifItem = {
id: string
gif: Gif
altText: string
}
export type PostMedia =
| {kind: 'images'; items: ImageItem[]}
| {kind: 'video'; item: VideoItem}
| {kind: 'gif'; item: GifItem}
export type ExternalEmbed = {
uri: string
}
export type Quote = {
uri: string
cid: string
}
export type PostDraft = {
id: string
text: string
langs: string[]
labels: string[]
media: PostMedia | undefined
external: ExternalEmbed | undefined
quote: Quote | undefined
}
export type ReplyTo = {
uri: string
cid: string
authorDid: string
view?: AppBskyFeedDefs.PostView
}
export type ComposerState = {
posts: PostDraft[]
replyTo: ReplyTo | undefined
/** ID of the saved draft this composer was opened from, if any. */
draftId: string | undefined
/** True when local state has diverged from the loaded draft (or initial open state). */
isDirty: boolean
}
export type NewImageInput = {
uri: string
width: number
height: number
altText?: string
}
export type NewVideoInput = {
uri: string
width: number
height: number
mimeType: string
altText?: string
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Background upload worker.
*
* Currently simulated with timers so the rest of the store can be wired up and
* tested. Each public function kicks off a series of setUploadStatus calls
* that drive a media item from pending -> uploading (with progress ticks) ->
* uploaded (or failed).
*
* TODO: replace the simulated progression with real AtpAgent.uploadBlob calls
* for images (via com.atproto.repo.uploadBlob) and the video upload pipeline
* for videos (job creation + polling). The public surface here should not need
* to change; the simulation lives entirely inside startImageUpload /
* startVideoUpload.
*/
import {type AtpAgent, type BlobRef} from '@atproto/api'
import {type UploadStatus} from './types'
type UploadHandle = {
mediaId: string
uri: string
agent: AtpAgent
setUploadStatus: (mediaId: string, status: UploadStatus) => void
/** Returns false if the store has been destroyed; aborts in-flight progression. */
isAlive: () => boolean
}
const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75]
const IMAGE_TICK_MS = 100
const VIDEO_PROGRESS_STEPS = [0.1, 0.3, 0.5, 0.7, 0.9]
const VIDEO_TICK_MS = 200
export function startImageUpload(handle: UploadHandle) {
// TODO: replace simulation with agent.uploadBlob({...}) and update progress
// from the underlying request. For now, drip progress then resolve.
runSimulatedUpload(handle, IMAGE_PROGRESS_STEPS, IMAGE_TICK_MS)
}
export function startVideoUpload(handle: UploadHandle) {
// TODO: replace simulation with the real video pipeline (compress, create
// upload job, poll job status, resolve to a BlobRef). Same status contract.
runSimulatedUpload(handle, VIDEO_PROGRESS_STEPS, VIDEO_TICK_MS)
}
function runSimulatedUpload(
handle: UploadHandle,
progressSteps: number[],
tickMs: number,
) {
let cancelled = false
let stepIndex = 0
function tick() {
if (cancelled || !handle.isAlive()) return
if (stepIndex === 0) {
handle.setUploadStatus(handle.mediaId, {
state: 'uploading',
progress: progressSteps[0],
})
} else if (stepIndex < progressSteps.length) {
handle.setUploadStatus(handle.mediaId, {
state: 'uploading',
progress: progressSteps[stepIndex],
})
} else {
handle.setUploadStatus(handle.mediaId, {
state: 'uploaded',
blob: makePlaceholderBlobRef(),
})
return
}
stepIndex += 1
setTimeout(tick, tickMs)
}
setTimeout(tick, tickMs)
}
/**
* Placeholder until the real upload returns a BlobRef from the server.
* Shape matches @atproto/api's BlobRef; the inner ref is a synthetic CID-like
* string. Real code path will overwrite this entirely.
*/
function makePlaceholderBlobRef(): BlobRef {
return {
$type: 'blob',
ref: {$link: 'simulated-upload-placeholder'},
mimeType: 'application/octet-stream',
size: 0,
} as unknown as BlobRef
}
@@ -0,0 +1,403 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type AtpAgent,
} from '@atproto/api'
import {beforeEach, describe, expect, jest, test} from '@jest/globals'
// Avoid pulling the UI module chain (gallery → media picker → ALF) into the
// test environment. Tests inject `__resolveLink` directly, so the real
// implementation is never invoked. We do mirror EmbeddingDisabledError so
// `instanceof` checks in parseErrorCode still match the imported class.
jest.mock('#/lib/api/resolve', () => {
class EmbeddingDisabledError extends Error {
constructor() {
super('Embedding is disabled for this record')
}
}
return {
resolveLink: jest.fn(),
EmbeddingDisabledError,
}
})
jest.mock('#/state/session/agent', () => ({
createPublicAgent: jest.fn(() => ({})),
}))
import {
EmbeddingDisabledError,
type ResolvedLink,
type resolveLink,
} from '#/lib/api/resolve'
import {createThreadStore} from '#/components/ComposerV2/store'
const POST_URL = 'https://bsky.app/profile/test.bsky.social/post/abc'
const EXTERNAL_URL = 'https://example.com'
function makeIdGenerator() {
let i = 0
return () => `id-${++i}`
}
const agent = {} as AtpAgent
function rootId(store: ReturnType<typeof createThreadStore>) {
return Object.keys(store.getState().posts)[0]
}
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (err: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return {promise, resolve, reject}
}
async function flushPromises() {
await new Promise(resolve => setImmediate(resolve))
}
let mockResolveLink: jest.Mock<typeof resolveLink>
beforeEach(() => {
mockResolveLink = jest.fn() as unknown as jest.Mock<typeof resolveLink>
})
function makeStore() {
return createThreadStore({
agent,
__createId: makeIdGenerator(),
__resolveLink: mockResolveLink,
})
}
const fakePostView = (uri: string, cid: string) =>
({uri, cid}) as unknown as AppBskyFeedDefs.PostView
const fakeGeneratorView = (uri: string, cid: string) =>
({uri, cid}) as unknown as AppBskyFeedDefs.GeneratorView
const fakeListView = (uri: string, cid: string) =>
({uri, cid}) as unknown as AppBskyGraphDefs.ListView
const fakeStarterPackView = (uri: string, cid: string) =>
({uri, cid}) as unknown as AppBskyGraphDefs.StarterPackView
const postLink: ResolvedLink = {
type: 'record',
kind: 'post',
record: {uri: 'at://post', cid: 'cp'},
view: fakePostView('at://post', 'cp'),
}
const feedLink: ResolvedLink = {
type: 'record',
kind: 'feed',
record: {uri: 'at://feed', cid: 'cf'},
view: fakeGeneratorView('at://feed', 'cf'),
}
const listLink: ResolvedLink = {
type: 'record',
kind: 'list',
record: {uri: 'at://list', cid: 'cl'},
view: fakeListView('at://list', 'cl'),
}
const starterPackLink: ResolvedLink = {
type: 'record',
kind: 'starter-pack',
record: {uri: 'at://sp', cid: 'csp'},
view: fakeStarterPackView('at://sp', 'csp'),
}
const externalLink: ResolvedLink = {
type: 'external',
uri: EXTERNAL_URL,
title: 'Example',
description: 'A description',
thumb: undefined,
}
const chatInviteLink: ResolvedLink = {
type: 'chat-invite',
uri: 'https://bsky.app/messages/join/abc123',
code: 'abc123',
view: undefined,
}
describe('addUri pre-classifies bsky post URLs to the quote slot', () => {
test('post URL → quote.pending → quote.resolved (with view)', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
const pending = store.getState().posts[root].quote
if (pending?.state !== 'pending') throw new Error('expected pending')
expect(pending.uri).toBe(POST_URL)
expect(store.getState().posts[root].embed).toBeUndefined()
d.resolve(postLink)
await flushPromises()
const quote = store.getState().posts[root].quote
if (quote?.state !== 'resolved') throw new Error('expected resolved')
expect(quote.uri).toBe('at://post')
expect(quote.cid).toBe('cp')
expect(quote.view).toBe(
postLink.kind === 'post' ? postLink.view : undefined,
)
})
test('addUri is a no-op when quote is already set', () => {
const store = makeStore()
const root = rootId(store)
store.actions.setQuoteEmbed(root, {uri: 'at://existing', cid: 'cx'})
const before = store.getState()
store.actions.addUri(root, POST_URL)
expect(store.getState()).toBe(before)
expect(mockResolveLink).not.toHaveBeenCalled()
})
test('post URL with media still routes to quote (orthogonal to media)', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
])
store.actions.addUri(root, POST_URL)
d.resolve(postLink)
await flushPromises()
expect(store.getState().posts[root].quote?.state).toBe('resolved')
expect(store.getState().posts[root].media).toHaveLength(1)
})
test('post resolution failure produces quote.failed with bound retry()', async () => {
const d1 = deferred<ResolvedLink>()
const d2 = deferred<ResolvedLink>()
mockResolveLink
.mockReturnValueOnce(d1.promise)
.mockReturnValueOnce(d2.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
d1.reject(new Error('post deleted'))
await flushPromises()
const failed = store.getState().posts[root].quote
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.error).toContain('post deleted')
expect(failed.code).toBe('unknown')
expect(typeof failed.retry).toBe('function')
failed.retry?.()
expect(store.getState().posts[root].quote?.state).toBe('pending')
d2.resolve(postLink)
await flushPromises()
expect(store.getState().posts[root].quote?.state).toBe('resolved')
})
test('embedding-disabled error is non-retryable (no retry on failed)', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
d.reject(new EmbeddingDisabledError())
await flushPromises()
const failed = store.getState().posts[root].quote
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.code).toBe('embedding-disabled')
expect(failed.retry).toBeUndefined()
})
})
describe('addUri pre-classifies non-post URLs to the embed slot', () => {
test('feed → embed.feed', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
expect(store.getState().posts[root].embed?.state).toBe('pending')
d.resolve(feedLink)
await flushPromises()
expect(store.getState().posts[root].embed?.state).toBe('feed')
})
test('list → embed.list', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d.resolve(listLink)
await flushPromises()
expect(store.getState().posts[root].embed?.state).toBe('list')
})
test('starter-pack → embed["starter-pack"]', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d.resolve(starterPackLink)
await flushPromises()
expect(store.getState().posts[root].embed?.state).toBe('starter-pack')
})
test('external → embed.external', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d.resolve(externalLink)
await flushPromises()
const embed = store.getState().posts[root].embed
if (embed?.state !== 'external') throw new Error('expected external')
expect(embed.title).toBe('Example')
})
test('chat-invite → embed["chat-invite"]', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d.resolve(chatInviteLink)
await flushPromises()
const embed = store.getState().posts[root].embed
if (embed?.state !== 'chat-invite') throw new Error('expected chat-invite')
expect(embed.code).toBe('abc123')
})
test('addUri is a no-op when embed has settled (resolved)', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d.resolve(externalLink)
await flushPromises()
expect(store.getState().posts[root].embed?.state).toBe('external')
store.actions.addUri(root, 'https://other.example')
// Settled slot blocks the second addUri.
expect(mockResolveLink).toHaveBeenCalledTimes(1)
expect(store.getState().posts[root].embed?.state).toBe('external')
})
test('addUri replaces a pending embed (e.g. user pastes a different URL)', () => {
mockResolveLink.mockReturnValue(new Promise(() => {}))
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
store.actions.addUri(root, 'https://other.example')
expect(mockResolveLink).toHaveBeenCalledTimes(2)
const embed = store.getState().posts[root].embed
if (embed?.state !== 'pending') throw new Error('expected pending')
expect(embed.uri).toBe('https://other.example')
})
test('addUri is a no-op when media is set (target is embed)', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [
{kind: 'image', uri: 'file:///a.jpg', width: 10, height: 10},
])
store.actions.addUri(root, EXTERNAL_URL)
expect(store.getState().posts[root].embed).toBeUndefined()
expect(mockResolveLink).not.toHaveBeenCalled()
})
test('embed resolution failure produces embed.failed with bound retry()', async () => {
const d1 = deferred<ResolvedLink>()
const d2 = deferred<ResolvedLink>()
mockResolveLink
.mockReturnValueOnce(d1.promise)
.mockReturnValueOnce(d2.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
d1.reject(new Error('network down'))
await flushPromises()
const failed = store.getState().posts[root].embed
if (failed?.state !== 'failed') throw new Error('expected failed')
expect(failed.error).toContain('network down')
expect(failed.code).toBe('unknown')
failed.retry?.()
expect(store.getState().posts[root].embed?.state).toBe('pending')
d2.resolve(externalLink)
await flushPromises()
expect(store.getState().posts[root].embed?.state).toBe('external')
})
})
describe('addUri cancellation by ignoring stale responses', () => {
test('removeEmbed before the response lands keeps embed undefined', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, EXTERNAL_URL)
store.actions.removeEmbed(root)
d.resolve(externalLink)
await flushPromises()
expect(store.getState().posts[root].embed).toBeUndefined()
})
test('removeQuoteEmbed before the response lands keeps quote undefined', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
store.actions.removeQuoteEmbed(root)
d.resolve(postLink)
await flushPromises()
expect(store.getState().posts[root].quote).toBeUndefined()
})
test('removeEmbed does not invalidate an in-flight quote resolution', async () => {
const d = deferred<ResolvedLink>()
mockResolveLink.mockReturnValueOnce(d.promise)
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, POST_URL)
store.actions.removeEmbed(root) // unrelated slot
d.resolve(postLink)
await flushPromises()
expect(store.getState().posts[root].quote?.state).toBe('resolved')
})
})
describe('setQuoteEmbed / removeQuoteEmbed', () => {
test('sets the quote embed and marks state dirty', () => {
const store = makeStore()
const root = rootId(store)
store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'})
const quote = store.getState().posts[root].quote
if (quote?.state !== 'resolved') throw new Error('expected resolved')
expect(quote.uri).toBe('at://x')
expect(quote.cid).toBe('c')
expect(store.getState().isDirty).toBe(true)
})
test('removeQuoteEmbed clears the quote', () => {
const store = makeStore()
const root = rootId(store)
store.actions.setQuoteEmbed(root, {uri: 'at://x', cid: 'c'})
store.actions.removeQuoteEmbed(root)
expect(store.getState().posts[root].quote).toBeUndefined()
})
})
@@ -0,0 +1,515 @@
import {type AtpAgent} from '@atproto/api'
import {beforeEach, describe, expect, jest, test} from '@jest/globals'
// Avoid pulling the UI module chain (gallery → media picker → ALF) into the
// test environment via the resolveLink import in linkResolution.ts.
jest.mock('#/lib/api/resolve', () => ({
resolveLink: jest.fn(),
}))
jest.mock('#/state/session/agent', () => ({
createPublicAgent: jest.fn(() => ({})),
}))
import {type resolveLink} from '#/lib/api/resolve'
import {createThreadStore} from '#/components/ComposerV2/store'
import {
type AddMediaInput,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
import {type Gif} from '#/features/gifPicker/types'
function makeIdGenerator() {
let i = 0
return () => `id-${++i}`
}
const agent = {} as AtpAgent
function rootId(store: ReturnType<typeof createThreadStore>) {
return Object.keys(store.getState().posts)[0]
}
function getMedia(
store: ReturnType<typeof createThreadStore>,
postId: string,
): PostEmbedMedia[] {
return store.getState().posts[postId].media
}
const imageInput: AddMediaInput = {
kind: 'image',
uri: 'file:///tmp/a.jpg',
width: 100,
height: 100,
}
const videoInput: AddMediaInput = {
kind: 'video',
uri: 'file:///tmp/a.mp4',
width: 1920,
height: 1080,
mimeType: 'video/mp4',
}
const gifInput: AddMediaInput = {
kind: 'gif',
// Tests don't read inside the gif, so a minimal cast is fine.
gif: {url: 'https://example.com/g.gif'} as Gif,
}
// Embed-routing tests live in embeds.test.ts; here we just need a never-
// resolving resolveLink so any addUri-driven resolution doesn't crash and
// no result ever lands. The promise never settles, which is what we want.
let mockResolveLink: jest.Mock<typeof resolveLink>
beforeEach(() => {
jest.useFakeTimers()
mockResolveLink = jest.fn(
() => new Promise(() => {}),
) as unknown as jest.Mock<typeof resolveLink>
})
function makeStore() {
return createThreadStore({
agent,
__createId: makeIdGenerator(),
__resolveLink: mockResolveLink,
})
}
describe('addMedia', () => {
test('adds a single image with pending upload status and returns its id', () => {
const store = makeStore()
const root = rootId(store)
const ids = store.actions.addMedia(root, [imageInput])
expect(ids).toEqual(['id-2'])
const media = getMedia(store, root)
expect(media).toHaveLength(1)
expect(media[0].kind).toBe('image')
expect(media[0].id).toBe('id-2')
expect(media[0].postId).toBe(root)
if (media[0].kind !== 'image') throw new Error('expected image')
expect(media[0].upload).toEqual({state: 'pending'})
})
test('preserves input order in returned ids and on the post', () => {
const store = makeStore()
const root = rootId(store)
const ids = store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
])
expect(ids).toHaveLength(3)
expect(getMedia(store, root).map(m => m.id)).toEqual(ids)
})
test('marks state dirty', () => {
const store = makeStore()
expect(store.getState().isDirty).toBe(false)
store.actions.addMedia(rootId(store), [imageInput])
expect(store.getState().isDirty).toBe(true)
})
test('returns undefined and is a no-op when post id is unknown', () => {
const store = makeStore()
const before = store.getState()
const result = store.actions.addMedia('does-not-exist', [imageInput])
expect(result).toBeUndefined()
expect(store.getState()).toBe(before)
})
test('returns [] for an empty input list and is a no-op', () => {
const store = makeStore()
const before = store.getState()
const result = store.actions.addMedia(rootId(store), [])
expect(result).toEqual([])
expect(store.getState()).toBe(before)
})
test('drives an image upload from pending -> uploading -> uploaded', () => {
const store = makeStore()
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('pending')
jest.advanceTimersByTime(100)
expect(get().state).toBe('uploading')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('drives a video upload through to uploaded', () => {
const store = makeStore()
const root = rootId(store)
const [videoId] = store.actions.addMedia(root, [videoInput])!
const get = () => {
const m = getMedia(store, root).find(x => x.id === videoId)!
if (m.kind !== 'video') throw new Error('expected video')
return m.upload
}
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('does not start an upload task for a gif', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
jest.runAllTimers()
const media = getMedia(store, root)
expect(media[0].kind).toBe('gif')
// Gif media records have no `upload` field.
expect('upload' in media[0]).toBe(false)
})
})
describe('addMedia input validation (first item dictates kind, cap by count)', () => {
test('image-first: filters out non-images and caps at 4', () => {
const store = makeStore()
const root = rootId(store)
const ids = store.actions.addMedia(root, [
imageInput,
videoInput,
imageInput,
imageInput,
gifInput,
imageInput,
imageInput,
])
expect(ids).toHaveLength(4)
const media = getMedia(store, root)
expect(media).toHaveLength(4)
expect(media.every(m => m.kind === 'image')).toBe(true)
})
test('video-first: filters out non-videos and caps at 1', () => {
const store = makeStore()
const root = rootId(store)
const ids = store.actions.addMedia(root, [
videoInput,
imageInput,
videoInput,
])
expect(ids).toHaveLength(1)
expect(getMedia(store, root)[0].kind).toBe('video')
})
test('gif-first: filters out non-gifs and caps at 1', () => {
const store = makeStore()
const root = rootId(store)
const ids = store.actions.addMedia(root, [gifInput, gifInput, imageInput])
expect(ids).toHaveLength(1)
expect(getMedia(store, root)[0].kind).toBe('gif')
})
})
describe('addMedia respects existing media on the post', () => {
test('appends images up to a total of 4 when the post already has images', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput, imageInput])
const ids = store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
])
// Two existing + capacity of 2 more.
expect(ids).toHaveLength(2)
expect(getMedia(store, root)).toHaveLength(4)
})
test('drops non-image inputs when the post already has images', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const ids = store.actions.addMedia(root, [videoInput, gifInput])
expect(ids).toEqual([])
expect(getMedia(store, root)).toHaveLength(1)
})
test('is a no-op when the post already has 4 images', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [
imageInput,
imageInput,
imageInput,
imageInput,
])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
test('is a no-op when the post already has a video', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [videoInput])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput, gifInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
test('is a no-op when the post already has a gif', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput, videoInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
test('is a no-op when the post has an external link card', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, 'https://example.com')
const before = store.getState()
const ids = store.actions.addMedia(root, [imageInput])
expect(ids).toEqual([])
expect(store.getState()).toBe(before)
})
})
describe('selectionsRemaining flags on the post', () => {
test('empty post starts with 4 / 1 / 1', () => {
const store = makeStore()
const post = store.getState().posts[rootId(store)]
expect(post.imageSelectionsRemaining).toBe(4)
expect(post.videoSelectionsRemaining).toBe(1)
expect(post.gifSelectionsRemaining).toBe(1)
})
test('decrements as images are added', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput, imageInput])
let post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(2)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
store.actions.addMedia(root, [imageInput, imageInput])
post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
})
test('a video locks all three counters to 0', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [videoInput])
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
})
test('a gif locks all three counters to 0', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [gifInput])
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
})
test('removing media restores capacity', () => {
const store = makeStore()
const root = rootId(store)
const [imgId] = store.actions.addMedia(root, [imageInput])!
expect(store.getState().posts[root].imageSelectionsRemaining).toBe(3)
store.actions.removeMedia(root, imgId)
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(4)
expect(post.videoSelectionsRemaining).toBe(1)
expect(post.gifSelectionsRemaining).toBe(1)
})
test('an external link card locks all three counters to 0', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, 'https://example.com')
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(0)
expect(post.videoSelectionsRemaining).toBe(0)
expect(post.gifSelectionsRemaining).toBe(0)
})
test('removing the external link card restores capacity', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addUri(root, 'https://example.com')
store.actions.removeEmbed(root)
const post = store.getState().posts[root]
expect(post.imageSelectionsRemaining).toBe(4)
expect(post.videoSelectionsRemaining).toBe(1)
expect(post.gifSelectionsRemaining).toBe(1)
})
})
describe('removeMedia', () => {
test('removes the matching media and leaves others intact', () => {
const store = makeStore()
const root = rootId(store)
const [a, b] = store.actions.addMedia(root, [imageInput, imageInput])!
store.actions.removeMedia(root, a)
expect(getMedia(store, root).map(m => m.id)).toEqual([b])
})
test('cancels in-flight upload (no further status writes after removal)', () => {
const store = makeStore()
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
jest.advanceTimersByTime(100)
store.actions.removeMedia(root, imageId)
expect(() => jest.runAllTimers()).not.toThrow()
expect(getMedia(store, root)).toHaveLength(0)
})
test('is a no-op when media id is unknown', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const before = store.getState()
store.actions.removeMedia(root, 'does-not-exist')
expect(store.getState()).toBe(before)
})
})
describe('retryMediaUpload', () => {
test('resets a failed image upload back to pending and walks it to uploaded', () => {
const store = makeStore()
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'boom',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
expect(get().state).toBe('failed')
store.actions.retryMediaUpload(root, imageId)
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('failed status carries a bound retry() method that restarts the upload', () => {
const store = makeStore()
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
store.actions.setUploadStatus(root, imageId, {
state: 'failed',
error: 'network',
})
const get = () => {
const m = getMedia(store, root).find(x => x.id === imageId)!
if (m.kind !== 'image') throw new Error('expected image')
return m.upload
}
const failed = get()
if (failed.state !== 'failed') throw new Error('expected failed')
expect(typeof failed.retry).toBe('function')
failed.retry()
expect(get().state).toBe('pending')
jest.runAllTimers()
expect(get().state).toBe('uploaded')
})
test('is a no-op for a gif media id', () => {
const store = makeStore()
const root = rootId(store)
const [gifId] = store.actions.addMedia(root, [gifInput])!
const before = store.getState()
store.actions.retryMediaUpload(root, gifId)
expect(store.getState()).toBe(before)
})
test('is a no-op when post or media id is unknown', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
const before = store.getState()
store.actions.retryMediaUpload(root, 'does-not-exist')
expect(store.getState()).toBe(before)
store.actions.retryMediaUpload('nope', 'whatever')
expect(store.getState()).toBe(before)
})
})
describe('updateMediaAltText', () => {
test('updates only the matching media (image)', () => {
const store = makeStore()
const root = rootId(store)
const [a, b] = store.actions.addMedia(root, [imageInput, imageInput])!
store.actions.updateMediaAltText(root, b, 'a description')
const media = getMedia(store, root)
expect(media.find(m => m.id === a)?.altText).toBe('')
expect(media.find(m => m.id === b)?.altText).toBe('a description')
})
test('works on a gif as well', () => {
const store = makeStore()
const root = rootId(store)
const [gifId] = store.actions.addMedia(root, [gifInput])!
store.actions.updateMediaAltText(root, gifId, 'animated joy')
expect(getMedia(store, root)[0].altText).toBe('animated joy')
})
test('is a no-op when alt text is unchanged', () => {
const store = makeStore()
const root = rootId(store)
const [imageId] = store.actions.addMedia(root, [imageInput])!
const before = store.getState()
store.actions.updateMediaAltText(root, imageId, '')
expect(store.getState()).toBe(before)
})
})
describe('removePost cancels media uploads', () => {
test('removing a post cancels any in-flight uploads on that post', () => {
const store = makeStore()
const a = rootId(store)
const b = store.actions.addPost('after', a)
store.actions.addMedia(b, [imageInput])
jest.advanceTimersByTime(100)
store.actions.removePost(b)
expect(() => jest.runAllTimers()).not.toThrow()
expect(Object.keys(store.getState().posts)).toEqual([a])
})
})
describe('destroy cancels uploads', () => {
test('destroy stops any in-flight uploads', () => {
const store = makeStore()
const root = rootId(store)
store.actions.addMedia(root, [imageInput])
jest.advanceTimersByTime(100)
store.destroy()
expect(() => jest.runAllTimers()).not.toThrow()
})
})
@@ -0,0 +1,78 @@
import {type AtpAgent} from '@atproto/api'
import {describe, expect, jest, test} from '@jest/globals'
// Avoid pulling the UI module chain into the test environment via the
// resolveLink import in linkResolution.ts.
jest.mock('#/lib/api/resolve', () => ({
resolveLink: jest.fn(),
}))
jest.mock('#/state/session/agent', () => ({
createPublicAgent: jest.fn(() => ({})),
}))
import {createThreadStore} from '#/components/ComposerV2/store'
function makeIdGenerator() {
let i = 0
return () => `id-${++i}`
}
const agent = {} as AtpAgent
function rootId(store: ReturnType<typeof createThreadStore>) {
return Object.keys(store.getState().posts)[0]
}
describe('subscribe / getState', () => {
test('listener fires on a real change and getState returns a new reference', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const before = store.getState()
const fn = jest.fn()
const unsubscribe = store.subscribe(fn)
store.actions.setPostText(root, 'hello')
expect(fn).toHaveBeenCalledTimes(1)
const after = store.getState()
expect(after).not.toBe(before)
expect(after.posts[root].text).toBe('hello')
unsubscribe()
})
test('listener does not fire on a no-op and state ref is preserved', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
const fn = jest.fn()
store.subscribe(fn)
store.actions.setPostText('does-not-exist', 'hello')
expect(fn).not.toHaveBeenCalled()
expect(store.getState()).toBe(before)
})
test('unsubscribed listeners stop receiving notifications', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const fn = jest.fn()
const unsubscribe = store.subscribe(fn)
store.actions.setPostText(root, 'a')
expect(fn).toHaveBeenCalledTimes(1)
unsubscribe()
store.actions.setPostText(root, 'b')
expect(fn).toHaveBeenCalledTimes(1)
})
test('destroy clears subscribers and stops further notifications', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const fn = jest.fn()
store.subscribe(fn)
store.destroy()
store.actions.setPostText(root, 'after destroy')
expect(fn).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,120 @@
import {type AtpAgent} from '@atproto/api'
import {describe, expect, jest, test} from '@jest/globals'
// Avoid pulling the UI module chain into the test environment via the
// resolveLink import in linkResolution.ts.
jest.mock('#/lib/api/resolve', () => ({
resolveLink: jest.fn(),
}))
jest.mock('#/state/session/agent', () => ({
createPublicAgent: jest.fn(() => ({})),
}))
import {createThreadStore} from '#/components/ComposerV2/store'
function makeIdGenerator() {
let i = 0
return () => `id-${++i}`
}
const agent = {} as AtpAgent
function rootId(store: ReturnType<typeof createThreadStore>) {
return Object.keys(store.getState().posts)[0]
}
describe('addPost("after")', () => {
test('inserts a new post immediately after the target and returns its id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
expect(root).toBe('id-1')
const second = store.actions.addPost('after', root)
expect(second).toBe('id-2')
expect(Object.keys(store.getState().posts)).toEqual(['id-1', 'id-2'])
})
test('inserts mid-thread without disturbing surrounding order', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const second = store.actions.addPost('after', root) // id-2
const third = store.actions.addPost('after', second) // id-3
const between = store.actions.addPost('after', root) // id-4
expect(Object.keys(store.getState().posts)).toEqual([
root,
between,
second,
third,
])
})
test('marks state dirty', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
expect(store.getState().isDirty).toBe(false)
store.actions.addPost('after', rootId(store))
expect(store.getState().isDirty).toBe(true)
})
test('is a no-op when the target id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
store.actions.addPost('after', 'does-not-exist')
expect(store.getState()).toBe(before)
expect(Object.keys(store.getState().posts).length).toBe(1)
})
})
describe('addPost("before")', () => {
test('inserts a new post immediately before the target and returns its id', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const root = rootId(store)
const newId = store.actions.addPost('before', root)
expect(newId).toBe('id-2')
expect(Object.keys(store.getState().posts)).toEqual([newId, root])
})
test('inserts mid-thread without disturbing surrounding order', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const b = store.actions.addPost('after', a)
const c = store.actions.addPost('after', b)
const before = store.actions.addPost('before', c)
expect(Object.keys(store.getState().posts)).toEqual([a, b, before, c])
})
test('is a no-op when the target id is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const before = store.getState()
store.actions.addPost('before', 'does-not-exist')
expect(store.getState()).toBe(before)
})
})
describe('removePost', () => {
test('removes the matching post and marks dirty', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const b = store.actions.addPost('after', a)
store.actions.removePost(b)
expect(Object.keys(store.getState().posts)).toEqual([a])
expect(store.getState().isDirty).toBe(true)
})
test('refuses to remove the last remaining post', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
const a = rootId(store)
const before = store.getState()
store.actions.removePost(a)
expect(store.getState()).toBe(before)
expect(Object.keys(store.getState().posts).length).toBe(1)
})
test('is a no-op when postId is unknown', () => {
const store = createThreadStore({agent, __createId: makeIdGenerator()})
store.actions.addPost('after', rootId(store))
const before = store.getState()
store.actions.removePost('does-not-exist')
expect(store.getState()).toBe(before)
expect(Object.keys(store.getState().posts).length).toBe(2)
})
})
+5
View File
@@ -0,0 +1,5 @@
/**
* Maximum number of images that can be attached to a single post.
* Mirrors the bsky lex limit on app.bsky.embed.images.
*/
export const MAX_IMAGES_PER_POST = 4
+669
View File
@@ -0,0 +1,669 @@
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {
type ResolvedLink,
resolveLink as importedResolveLink,
type resolveLink,
} from '#/lib/api/resolve'
import {createPublicAgent} from '#/state/session/agent'
import type * as types from '#/components/ComposerV2/store/types'
import {
startImageUpload,
startVideoUpload,
type UploadTask,
} from '#/components/ComposerV2/store/uploads'
import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostMediaItem'
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUriTarget'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
import {createAsyncTaskRev} from '#/components/ComposerV2/store/utils/createAsyncTaskRev'
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
import {parseResolveLinkError} from '#/components/ComposerV2/store/utils/parseResolveLinkError'
type Listener = () => void
export function createThreadStore(options: {
agent: AtpAgent
/** Override id generation; useful for deterministic tests. */
__createId?: () => string
/** Override link resolver; useful for deterministic tests. */
__resolveLink?: typeof resolveLink
}) {
const id = options.__createId ?? nanoid
const agent = options.agent
const resolveLinkOverride = options.__resolveLink
let state: types.ThreadState = {
posts: {[id()]: buildThreadPost()},
isDirty: false,
draftId: undefined,
}
const listeners = new Set<Listener>()
let destroyed = false
/**
* In-flight upload tasks keyed by media id. Held outside of state because
* cancellation handles aren't serializable. Cleared on terminal status
* (uploaded/failed) and on store destroy.
*/
const uploadTasks = new Map<string, UploadTask>()
/**
* Per-slot revision counters. Quote and embed are orthogonal slots, so
* each has its own counter; invalidating one doesn't invalidate the
* other. Every action that starts or supersedes a resolution for a slot
* calls `incrementFor(postId)`, and the worker callback closes over the
* returned `isCurrent` checker to decide whether to write back.
*/
const quoteRev = createAsyncTaskRev()
const embedRev = createAsyncTaskRev()
/**
* Action bodies mutate `s` in place. Returning `null` signals a no-op (the
* state ref is preserved and listeners are not notified). Otherwise we
* shallow-clone both the top-level state and the inner `posts` object so
* any consumer of either reference sees a fresh value. (Some actions
* mutate `s.posts` in place, e.g. removePost's `delete s.posts[id]`;
* cloning posts here means selectors and React Compiler memoization can
* use ref equality reliably.)
*/
function mutateState(fn: (s: types.ThreadState) => types.ThreadState | null) {
if (destroyed) return
const next = fn(state)
if (next === null) return
state = {...next, posts: {...next.posts}}
for (const listener of listeners) listener()
}
/**
* Actions
*/
function setPostText(postId: string, text: string) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = {...post, text}
s.isDirty = true
return s
})
}
function setPostLanguages(postId: string, languages: string[]) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = {...post, langs: languages}
s.isDirty = true
return s
})
}
function setPostLabels(postId: string, labels: string[]) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = {...post, labels}
s.isDirty = true
return s
})
}
function addPost(position: 'before' | 'after', postId: string): string {
const newId = id()
mutateState(s => {
if (!(postId in s.posts)) return null
// Object key order is insertion order, so to insert mid-thread we
// rebuild the posts object.
const next: Record<string, types.ThreadPost> = {}
for (const [k, v] of Object.entries(s.posts)) {
if (position === 'before' && k === postId) {
next[newId] = buildThreadPost()
}
next[k] = v
if (position === 'after' && k === postId) {
next[newId] = buildThreadPost()
}
}
s.posts = next
s.isDirty = true
return s
})
return newId
}
function removePost(postId: string) {
mutateState(s => {
// The composer always has at least one post.
if (Object.keys(s.posts).length <= 1) return null
if (!(postId in s.posts)) return null
// Cancel any in-flight uploads for media on this post before dropping it.
for (const m of s.posts[postId].media) cancelUploadTask(m.id)
// Drop both rev entries so any stale resolution callbacks for this
// post can never write back into state.
quoteRev.clearFor(postId)
embedRev.clearFor(postId)
delete s.posts[postId]
s.isDirty = true
return s
})
}
/**
* Add one or more media items to a post and start any required uploads.
* Accepts a heterogeneous list (images, video, gif). Gifs don't kick off
* an upload task; images and videos do.
*
* Returns the new media ids in input order, or undefined if the postId
* doesn't exist (no items are added in that case).
*/
function addMedia(
postId: string,
inputs: types.AddMediaInput[],
): string[] | undefined {
if (!(postId in state.posts)) return undefined
if (inputs.length === 0) return []
// Embeds (external link cards, feed/list/starter-pack record cards, and
// the in-flight pending state) are mutually exclusive with media. This
// rule is permanent (unlike the kind/cap rules in filterMediaInputs) so
// it lives here at the action boundary rather than inside the filter.
if (state.posts[postId].embed !== undefined) return []
const accepted = filterMediaInputs(state.posts[postId].media, inputs)
if (accepted.length === 0) return []
const newIds = accepted.map(() => id())
const newItems: types.PostEmbedMedia[] = accepted.map((input, i) =>
buildPostMediaItem(input, {id: newIds[i], postId}),
)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = setPostMedia(post, [...post.media, ...newItems])
s.isDirty = true
return s
})
for (let i = 0; i < accepted.length; i++) {
const input = accepted[i]
const mediaId = newIds[i]
if (input.kind === 'image') {
uploadTasks.set(
mediaId,
startImageUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
} else if (input.kind === 'video') {
uploadTasks.set(
mediaId,
startVideoUpload({
postId,
mediaId,
uri: input.uri,
agent,
setUploadStatus,
}),
)
}
// gif: no upload task
}
return newIds
}
function removeMedia(postId: string, mediaId: string) {
cancelUploadTask(mediaId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
const next = post.media.filter(m => m.id !== mediaId)
if (next.length === post.media.length) return null
s.posts[postId] = setPostMedia(post, next)
s.isDirty = true
return s
})
}
function updateMediaAltText(
postId: string,
mediaId: string,
altText: string,
) {
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
let changed = false
const media = post.media.map(m => {
if (m.id !== mediaId) return m
if (m.altText === altText) return m
changed = true
return {...m, altText}
})
if (!changed) return null
s.posts[postId] = setPostMedia(post, media)
s.isDirty = true
return s
})
}
/**
* Restart a failed (or in-flight) upload for an image or video. No-ops on
* a gif (no upload lifecycle) or on unknown ids.
*/
function retryMediaUpload(postId: string, mediaId: string) {
const post = state.posts[postId]
if (!post) return
const item = post.media.find(m => m.id === mediaId)
if (!item) return
if (item.kind === 'gif') return
cancelUploadTask(mediaId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
const media = p.media.map(m =>
m.id === mediaId ? {...m, upload: {state: 'pending' as const}} : m,
)
s.posts[postId] = setPostMedia(p, media)
return s
})
const start = item.kind === 'image' ? startImageUpload : startVideoUpload
uploadTasks.set(
mediaId,
start({
postId,
mediaId,
uri: item.uri,
agent,
setUploadStatus,
}),
)
}
/**
* Generic URI handler. Pre-classifies the URI from its URL pattern to
* decide which slot the eventual data will land in:
*
* - Bluesky post URL -> `quote` slot (coexists with media).
* - Anything else (feed / list / starter-pack / external) -> `embed` slot
* (mutually exclusive with media).
*
* Conflict checks happen synchronously based on the target slot:
* - If targeting quote and quote is already set -> no-op (preserves prior).
* - If targeting embed and embed is already set -> no-op.
* - If targeting embed and media is set -> no-op.
*
* Otherwise, pending state is written to the target slot synchronously and
* `resolveLink` runs in the background. The outcome lands in the same slot
* (resolved or failed). Cancellation is per-slot rev-based.
*/
function addUri(postId: string, uri: string) {
const post = state.posts[postId]
if (!post) return
const resolve = resolveLinkOverride ?? importedResolveLink
const target = classifyUriTarget(uri)
if (target === 'quote') {
// No-op only when the slot has a settled value. Pending and failed
// states are replaceable (failed.retry() relies on this).
if (post.quote?.state === 'resolved') return
const rev = quoteRev.incrementFor(postId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostQuote(p, {state: 'pending', uri})
s.isDirty = true
return s
})
resolve(createPublicAgent(), uri).then(
link => applyQuoteResolved(rev, postId, uri, link),
err => applyQuoteFailed(rev, postId, uri, err),
)
return
}
// target === 'embed'
// Same rule as quote: settled values block; pending / failed are
// replaceable (so retry() works on a failed embed).
const embedSettled =
post.embed !== undefined &&
post.embed.state !== 'pending' &&
post.embed.state !== 'failed'
if (embedSettled) return
if (post.media.length > 0) return
const rev = embedRev.incrementFor(postId)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostEmbed(p, {state: 'pending', uri})
s.isDirty = true
return s
})
resolve(createPublicAgent(), uri).then(
link => applyEmbedResolved(rev, postId, uri, link),
err => applyEmbedFailed(rev, postId, uri, err),
)
}
function applyQuoteResolved(
rev: number,
postId: string,
uri: string,
link: ResolvedLink,
) {
if (destroyed) return
if (!quoteRev.isCurrentFor(postId, rev)) return
// Pre-classification said this was a post URL. If resolveLink disagrees
// (rare, since both use the same URL patterns), surface as a generic
// failure in the quote slot.
if (link.type !== 'record' || link.kind !== 'post') {
applyQuoteFailed(rev, postId, uri, new Error('Could not resolve post'))
return
}
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostQuote(p, {
state: 'resolved',
uri: link.record.uri,
cid: link.record.cid,
view: link.view,
})
return s
})
}
function applyQuoteFailed(
rev: number,
postId: string,
uri: string,
err: unknown,
) {
if (destroyed) return
if (!quoteRev.isCurrentFor(postId, rev)) return
// Non-retryable failure codes (e.g. embedding-disabled) get a failed
// state with no `retry()`; the user has to remove the embed manually.
const {code, isRetryable} = parseResolveLinkError(err)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostQuote(p, {
state: 'failed',
uri,
error: stringifyError(err),
code,
retry: isRetryable ? () => addUri(postId, uri) : undefined,
})
return s
})
}
function applyEmbedResolved(
rev: number,
postId: string,
uri: string,
link: ResolvedLink,
) {
if (destroyed) return
if (!embedRev.isCurrentFor(postId, rev)) return
// Pre-classification said this was a non-post URL. If resolveLink
// surprises us with a post outcome, treat as failure rather than
// silently moving slots.
if (link.type === 'record' && link.kind === 'post') {
applyEmbedFailed(
rev,
postId,
uri,
new Error('Unexpected post outcome for non-post URL'),
)
return
}
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostEmbed(p, resolvedLinkToEmbed(link))
return s
})
}
function applyEmbedFailed(
rev: number,
postId: string,
uri: string,
err: unknown,
) {
if (destroyed) return
if (!embedRev.isCurrentFor(postId, rev)) return
const {code, isRetryable} = parseResolveLinkError(err)
mutateState(s => {
const p = s.posts[postId]
if (!p) return null
s.posts[postId] = setPostEmbed(p, {
state: 'failed',
uri,
error: stringifyError(err),
code,
retry: isRetryable ? () => addUri(postId, uri) : undefined,
})
return s
})
}
function removeEmbed(postId: string) {
embedRev.incrementFor(postId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
if (post.embed === undefined) return null
s.posts[postId] = setPostEmbed(post, undefined)
s.isDirty = true
return s
})
}
/**
* Direct setter for an already-resolved quote. Used for draft restore and
* any UI flow that already has the post ref+view in hand. Bumps the quote
* rev so any in-flight resolution is invalidated.
*/
function setQuoteEmbed(
postId: string,
ref: {uri: string; cid: string; view?: AppBskyFeedDefs.PostView},
) {
quoteRev.incrementFor(postId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
s.posts[postId] = setPostQuote(post, {
state: 'resolved',
uri: ref.uri,
cid: ref.cid,
view: ref.view,
})
s.isDirty = true
return s
})
}
function removeQuoteEmbed(postId: string) {
quoteRev.incrementFor(postId)
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
if (post.quote === undefined) return null
s.posts[postId] = setPostQuote(post, undefined)
s.isDirty = true
return s
})
}
/**
* Public so the simulated upload worker can push progress in. Real callers
* should not invoke this directly; use addMedia / retryMediaUpload.
*
* Failed inputs are wrapped here with a `retry()` method bound to this
* (postId, mediaId) so consumers reading the status from state can retry
* without having to look up the ids themselves.
*/
function setUploadStatus(
postId: string,
mediaId: string,
statusInput: types.UploadStatus,
) {
const status: types.PostMediaUploadStatus =
statusInput.state === 'failed'
? {...statusInput, retry: () => retryMediaUpload(postId, mediaId)}
: statusInput
mutateState(s => {
const post = s.posts[postId]
if (!post) return null
const idx = post.media.findIndex(m => m.id === mediaId)
if (idx === -1) return null
const found = post.media[idx]
// Gifs don't have an upload lifecycle.
if (found.kind === 'gif') return null
const media = post.media.slice()
media[idx] = {...found, upload: status}
s.posts[postId] = setPostMedia(post, media)
// Upload progress isn't a user edit, so don't mark dirty here.
return s
})
if (status.state === 'uploaded' || status.state === 'failed') {
uploadTasks.delete(mediaId)
}
}
function cancelUploadTask(mediaId: string) {
const task = uploadTasks.get(mediaId)
if (task) {
task.cancel()
uploadTasks.delete(mediaId)
}
}
/**
* Single chokepoint for replacing a post's media array. Recomputes the
* derived selectionsRemaining flags so they never drift from the array.
*/
function setPostMedia(
post: types.ThreadPost,
media: types.PostEmbedMedia[],
): types.ThreadPost {
return {
...post,
media,
...computePostMediaSelectionsRemaining(media, post.embed),
}
}
/**
* Single chokepoint for replacing a post's embed slot. Mirrors
* setPostMedia so the selectionsRemaining flags stay consistent (any
* embed - including pending and failed - blocks all media selections).
*/
function setPostEmbed(
post: types.ThreadPost,
embed: types.PostEmbed | undefined,
): types.ThreadPost {
return {
...post,
embed,
...computePostMediaSelectionsRemaining(post.media, embed),
}
}
/**
* Single chokepoint for replacing a post's quote slot. Quote is
* orthogonal to media so no selectionsRemaining recomputation is needed.
*/
function setPostQuote(
post: types.ThreadPost,
quote: types.PostEmbedQuote | undefined,
): types.ThreadPost {
return {...post, quote}
}
/**
* Map a non-post ResolvedLink into the PostEmbed shape stored on the post.
* Callers handle the post-record case separately (those go to quote).
*/
function resolvedLinkToEmbed(link: ResolvedLink): types.PostEmbed {
if (link.type === 'external') {
return {
state: 'external',
uri: link.uri,
title: link.title,
description: link.description,
thumb: link.thumb,
}
}
if (link.type === 'chat-invite') {
return {
state: 'chat-invite',
uri: link.uri,
code: link.code,
view: link.view,
}
}
switch (link.kind) {
case 'feed':
return {state: 'feed', record: link.record, view: link.view}
case 'list':
return {state: 'list', record: link.record, view: link.view}
case 'starter-pack':
return {state: 'starter-pack', record: link.record, view: link.view}
case 'post':
throw new Error('post records should route to quote, not embed')
}
}
function stringifyError(err: unknown): string {
return String((err && (err as Error).message) ?? err)
}
return {
actions: {
setPostText,
setPostLanguages,
setPostLabels,
addPost,
removePost,
addMedia,
removeMedia,
updateMediaAltText,
retryMediaUpload,
addUri,
removeEmbed,
setQuoteEmbed,
removeQuoteEmbed,
setUploadStatus,
},
destroy() {
destroyed = true
for (const task of uploadTasks.values()) task.cancel()
uploadTasks.clear()
quoteRev.clearAll()
embedRev.clearAll()
},
getState() {
return state
},
subscribe(listener: Listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
+229
View File
@@ -0,0 +1,229 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type BlobRef,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {type ComposerImage} from '#/state/gallery'
import {type Gif} from '#/features/gifPicker/types'
/**
* What an upload reporter (the worker, or a test) sends in. Failed inputs
* carry just the error string; the store wraps the failure with a bound
* `retry()` method when it stores the status.
*/
export type UploadStatus =
| {state: 'pending'}
| {state: 'uploading'; progress: number}
| {state: 'uploaded'; blob: BlobRef}
| {state: 'failed'; error: string}
/**
* What's stored on a media item. The failed variant has a bound `retry()` so
* UI can call it directly without having to look up postId/mediaId.
*
* Note: `retry` is a function reference and won't survive JSON serialization.
* On restore (OS-resume / draft load), the store re-attaches it.
*/
export type PostMediaUploadStatus =
| {state: 'pending'}
| {state: 'uploading'; progress: number}
| {state: 'uploaded'; blob: BlobRef}
| {state: 'failed'; error: string; retry: () => void}
export type PostEmbedMediaImage = {
id: string
/** Id of the post this media is attached to. */
postId: string
uri: string
width: number
height: number
altText: string
/**
* Stable path used to round-trip through saved drafts without re-copying
* bytes. Set when loaded from a draft, or generated at draft-save time for
* media that was added during this composer session.
*/
localRefPath?: string
upload: PostMediaUploadStatus
}
export type PostEmbedMediaVideo = {
id: string
/** Id of the post this media is attached to. */
postId: string
uri: string
width: number
height: number
altText: string
mimeType: string
/** See PostEmbedMediaImage.localRefPath. */
localRefPath?: string
captions: Array<{lang: string; content: string}>
upload: PostMediaUploadStatus
}
export type PostEmbedMediaGif = {
id: string
/** Id of the post this media is attached to. */
postId: string
gif: Gif
altText: string
}
/**
* A single piece of embedded media. A post's `media` is an array of these.
* The bsky semantics (up to 4 images OR 1 video OR 1 gif, never mixed) are
* enforced by the actions that mutate the array, not by this type.
*/
export type PostEmbedMedia =
| (PostEmbedMediaImage & {kind: 'image'})
| (PostEmbedMediaVideo & {kind: 'video'})
| (PostEmbedMediaGif & {kind: 'gif'})
/**
* Coarse classification of why a link resolution failed. Drives UI
* affordances — for example, `embedding-disabled` is a permanent rejection
* (embedding the post is forbidden by the author), so the failed variant
* does not carry a `retry()`. Anything else falls under `unknown` and is
* retryable.
*/
export type LinkResolutionFailureCode = 'embedding-disabled' | 'unknown'
/**
* What's stored on a post's `embed` field. The failed variant carries a
* bound `retry()` for retryable codes; for permanent failures (e.g.
* `embedding-disabled`) `retry` is omitted so UI can detect that case and
* surface a non-retryable message. The pending variant is set synchronously
* by addUri while resolution is in flight; the resolved variants
* (external/feed/list/starter-pack/chat-invite) land when the worker
* reports back.
*
* Note: `retry` is a function reference and won't survive JSON serialization.
* On restore (OS-resume / draft load), the store re-attaches it.
*
* (Worker-side input and outcome types live in linkResolution.ts.)
*/
export type PostEmbed =
| {state: 'pending'; uri: string}
| {
state: 'failed'
uri: string
error: string
code: LinkResolutionFailureCode
retry?: () => void
}
| {
state: 'external'
uri: string
title: string
description: string
thumb: ComposerImage | undefined
}
| {
state: 'feed'
record: ComAtprotoRepoStrongRef.Main
view: AppBskyFeedDefs.GeneratorView
}
| {
state: 'list'
record: ComAtprotoRepoStrongRef.Main
view: AppBskyGraphDefs.ListView
}
| {
state: 'starter-pack'
record: ComAtprotoRepoStrongRef.Main
view: AppBskyGraphDefs.StarterPackView
}
| {
state: 'chat-invite'
uri: string
code: string
view: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
}
/**
* What's stored on a post's `quote` field. Mirrors `PostEmbed`'s shape: the
* pending variant is set synchronously by addUri while the post is being
* resolved; the resolved variant lands when the worker reports back; the
* failed variant carries a bound `retry()`.
*
* `view` is optional on the resolved variant because `setQuoteEmbed` (used
* for direct programmatic insertion, e.g. draft restore) may not have a
* hydrated post view to hand.
*/
export type PostEmbedQuote =
| {state: 'pending'; uri: string}
| {
state: 'failed'
uri: string
error: string
code: LinkResolutionFailureCode
retry?: () => void
}
| {
state: 'resolved'
uri: string
cid: string
view?: AppBskyFeedDefs.PostView
}
export type ThreadPost = {
text: string
langs: string[]
labels: string[]
media: PostEmbedMedia[]
/** Single non-quote embed slot. Mutually exclusive with media. */
embed: PostEmbed | undefined
quote: PostEmbedQuote | undefined
/**
* Derived from `media`. How many more items of each kind addMedia would
* accept on this post given the current state. Kept in sync by the store
* whenever `media` is mutated; UI can read these directly to gate pickers.
*/
imageSelectionsRemaining: number
videoSelectionsRemaining: number
gifSelectionsRemaining: number
}
/**
* Input shape for addMedia. Each entry carries its own kind discriminator
* plus the kind-specific source fields. The store generates ids and
* postIds; callers don't deal with either.
*/
export type AddMediaInput =
| {
kind: 'image'
uri: string
width: number
height: number
altText?: string
}
| {
kind: 'video'
uri: string
width: number
height: number
mimeType: string
altText?: string
}
| {
kind: 'gif'
gif: Gif
altText?: string
}
export type ThreadState = {
/**
* Posts keyed by id. Insertion order is the thread order; rely on object
* key insertion-order semantics for ES2015+. Keys are nanoid strings so
* they will not be coerced into the integer-key bucket that re-sorts.
*/
posts: Record<string, ThreadPost>
/** ID of the saved draft this composer was opened from, if any. */
draftId: string | undefined
/** True when local state has diverged from the loaded draft (or initial open state). */
isDirty: boolean
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Background upload worker for the ComposerV2 store.
*
* Currently simulated with timers so the rest of the store can be wired up and
* tested. Each public function returns an UploadTask whose `cancel()` aborts
* any pending work; this is what the store stores per-media so it can cancel
* an in-flight upload when the user removes or replaces the media.
*
* TODO: replace the simulated progression with real implementations:
* - images: AtpAgent.uploadBlob (com.atproto.repo.uploadBlob)
* - video: the existing video pipeline (compress, create upload job, poll
* until ready, resolve to a BlobRef)
* The public surface here (startImageUpload / startVideoUpload returning an
* UploadTask) should not need to change; the simulation lives entirely
* inside runSimulatedUpload.
*/
import {type AtpAgent, type BlobRef} from '@atproto/api'
import {type UploadStatus} from './types'
export type UploadTask = {
cancel(): void
}
type StartUploadOptions = {
postId: string
mediaId: string
uri: string
agent: AtpAgent
setUploadStatus: (
postId: string,
mediaId: string,
status: UploadStatus,
) => void
}
const IMAGE_PROGRESS_STEPS = [0.25, 0.5, 0.75]
const IMAGE_TICK_MS = 100
const VIDEO_PROGRESS_STEPS = [0.1, 0.3, 0.5, 0.7, 0.9]
const VIDEO_TICK_MS = 200
export function startImageUpload(opts: StartUploadOptions): UploadTask {
return runSimulatedUpload(opts, IMAGE_PROGRESS_STEPS, IMAGE_TICK_MS)
}
export function startVideoUpload(opts: StartUploadOptions): UploadTask {
// TODO: replace with real video pipeline (compress, create upload job,
// poll until ready, resolve to a BlobRef).
return runSimulatedUpload(opts, VIDEO_PROGRESS_STEPS, VIDEO_TICK_MS)
}
function runSimulatedUpload(
opts: StartUploadOptions,
progressSteps: number[],
tickMs: number,
): UploadTask {
let cancelled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
let stepIndex = 0
function tick() {
timeoutId = null
if (cancelled) return
if (stepIndex < progressSteps.length) {
opts.setUploadStatus(opts.postId, opts.mediaId, {
state: 'uploading',
progress: progressSteps[stepIndex],
})
stepIndex += 1
timeoutId = setTimeout(tick, tickMs)
return
}
opts.setUploadStatus(opts.postId, opts.mediaId, {
state: 'uploaded',
blob: makePlaceholderBlobRef(),
})
}
timeoutId = setTimeout(tick, tickMs)
return {
cancel() {
if (cancelled) return
cancelled = true
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}
},
}
}
/**
* Placeholder until the real upload returns a BlobRef from the server.
* Real code path will overwrite this entirely.
*/
function makePlaceholderBlobRef(): BlobRef {
return {
$type: 'blob',
ref: {$link: 'simulated-upload-placeholder'},
mimeType: 'application/octet-stream',
size: 0,
} as unknown as BlobRef
}
@@ -0,0 +1,43 @@
import {
type AddMediaInput,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
export function buildPostMediaItem(
input: AddMediaInput,
ids: {id: string; postId: string},
): PostEmbedMedia {
if (input.kind === 'image') {
return {
kind: 'image',
id: ids.id,
postId: ids.postId,
uri: input.uri,
width: input.width,
height: input.height,
altText: input.altText ?? '',
upload: {state: 'pending'},
}
}
if (input.kind === 'video') {
return {
kind: 'video',
id: ids.id,
postId: ids.postId,
uri: input.uri,
width: input.width,
height: input.height,
mimeType: input.mimeType,
altText: input.altText ?? '',
captions: [],
upload: {state: 'pending'},
}
}
return {
kind: 'gif',
id: ids.id,
postId: ids.postId,
gif: input.gif,
altText: input.altText ?? '',
}
}
@@ -0,0 +1,14 @@
import {type ThreadPost} from '#/components/ComposerV2/store/types'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
export function buildThreadPost(): ThreadPost {
return {
text: '',
langs: [],
labels: [],
media: [],
embed: undefined,
quote: undefined,
...computePostMediaSelectionsRemaining([], undefined),
}
}
@@ -0,0 +1,17 @@
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
/**
* Which slot on a post a given URI is destined for. Bluesky post URLs go to
* the `quote` slot; everything else (feed/list/starter-pack records and
* generic external links) goes to the `embed` slot.
*
* Used by addUri to set the pending state on the correct slot synchronously,
* surface conflict no-ops upfront, and avoid a "data moves between slots"
* race after async resolution.
*/
export type EmbedTargetSlot = 'quote' | 'embed'
export function classifyUriTarget(uri: string): EmbedTargetSlot {
if (isBskyPostUrl(uri)) return 'quote'
return 'embed'
}
@@ -0,0 +1,47 @@
import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const'
import {
type PostEmbed,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
/**
* Mirrors filterMediaInputs' rules. With no media and no embed, all kinds
* are open at their per-kind cap. With existing images, only images are
* open up to a total of MAX_IMAGES_PER_POST. With an existing video, gif,
* or any embed (including pending and failed), nothing more can be added.
*/
export function computePostMediaSelectionsRemaining(
media: PostEmbedMedia[],
embed: PostEmbed | undefined,
): {
imageSelectionsRemaining: number
videoSelectionsRemaining: number
gifSelectionsRemaining: number
} {
if (embed !== undefined) {
return {
imageSelectionsRemaining: 0,
videoSelectionsRemaining: 0,
gifSelectionsRemaining: 0,
}
}
if (media.length === 0) {
return {
imageSelectionsRemaining: MAX_IMAGES_PER_POST,
videoSelectionsRemaining: 1,
gifSelectionsRemaining: 1,
}
}
if (media[0].kind === 'image') {
return {
imageSelectionsRemaining: Math.max(0, MAX_IMAGES_PER_POST - media.length),
videoSelectionsRemaining: 0,
gifSelectionsRemaining: 0,
}
}
return {
imageSelectionsRemaining: 0,
videoSelectionsRemaining: 0,
gifSelectionsRemaining: 0,
}
}
@@ -0,0 +1,48 @@
/**
* A keyed revision counter used to cancel stale async work. Callers
* `incrementFor(key)` to bump a key's revision and capture the new value,
* then later `isCurrentFor(key, rev)` to check whether their captured
* revision is still the live one. If it isn't, a newer caller has
* superseded them and they should bail.
*
* Example:
* const rev = task.incrementFor(postId)
* doAsyncWork().then(result => {
* if (!task.isCurrentFor(postId, rev)) return
* // ... write result to state
* })
*
* `clearFor(key)` drops the key - any later isCurrentFor call for that
* key returns false. Right hook to call from removePost.
* `clearAll()` drops every key - used on store destroy.
*/
export type AsyncTaskRev = {
/** Bump the key's revision and return the new value. */
incrementFor(key: string): number
/** True while `rev` is still the current revision for `key`. */
isCurrentFor(key: string, rev: number): boolean
/** Drop the key. Future isCurrentFor calls for this key return false. */
clearFor(key: string): void
/** Drop every key. Future isCurrentFor calls return false. */
clearAll(): void
}
export function createAsyncTaskRev(): AsyncTaskRev {
const counters = new Map<string, number>()
return {
incrementFor(key) {
const next = (counters.get(key) ?? 0) + 1
counters.set(key, next)
return next
},
isCurrentFor(key, rev) {
return counters.get(key) === rev
},
clearFor(key) {
counters.delete(key)
},
clearAll() {
counters.clear()
},
}
}
@@ -0,0 +1,39 @@
import {MAX_IMAGES_PER_POST} from '#/components/ComposerV2/store/const'
import {
type AddMediaInput,
type PostEmbedMedia,
} from '#/components/ComposerV2/store/types'
/**
* Filters and caps a list of addMedia inputs based on what's already on the
* post:
*
* - If the post already has a video or gif, addMedia is a no-op.
* - If the post already has images, only image inputs are accepted, up to a
* total of MAX_IMAGES_PER_POST (existing + new).
* - If the post has no media yet, the first input's kind dictates the kind
* for the call: items of any other kind are dropped, and the remainder is
* capped at the per-kind limit (4 images, 1 video, 1 gif).
*
* NOTE: this is likely temporary - the first-input-dictates-kind rule is a
* placeholder until the UI gates the picker properly. The mutual-exclusion
* with external link cards is permanent and is enforced by the caller
* (addMedia) before invoking this function.
*/
export function filterMediaInputs(
existing: PostEmbedMedia[],
inputs: AddMediaInput[],
): AddMediaInput[] {
if (existing.length > 0) {
// Existing media is locked into a single kind; only same-kind images
// can be appended, otherwise nothing more is accepted.
if (existing[0].kind !== 'image') return []
const remaining = MAX_IMAGES_PER_POST - existing.length
if (remaining <= 0) return []
return inputs.filter(i => i.kind === 'image').slice(0, remaining)
}
const kind = inputs[0].kind
const sameKind = inputs.filter(i => i.kind === kind)
const cap = kind === 'image' ? MAX_IMAGES_PER_POST : 1
return sameKind.slice(0, cap)
}
@@ -0,0 +1,26 @@
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {type LinkResolutionFailureCode} from '#/components/ComposerV2/store/types'
export type ParsedResolveLinkError = {
code: LinkResolutionFailureCode
/**
* Whether the failure is worth retrying. Today only EmbeddingDisabledError
* is permanent (the post's author has forbidden quoting); anything else
* is a transient or unknown failure and the caller should attach a
* `retry()` to the failed state.
*/
isRetryable: boolean
}
/**
* Classify a thrown error from `resolveLink` into a stable failure code
* and a retryability flag. Centralizes the retry-policy decision so the
* store doesn't repeat the `code === 'embedding-disabled'` check at every
* failure call site.
*/
export function parseResolveLinkError(err: unknown): ParsedResolveLinkError {
if (err instanceof EmbeddingDisabledError) {
return {code: 'embedding-disabled', isRetryable: false}
}
return {code: 'unknown', isRetryable: true}
}
+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(
+38 -3
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
@@ -95,10 +127,12 @@ export function ImageItem({
thumbnail,
alt,
children,
maxWidth = 100,
}: {
thumbnail?: string
alt?: string
children?: React.ReactNode
maxWidth?: number
}) {
const t = useTheme()
@@ -109,7 +143,7 @@ export function ImageItem({
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
{maxWidth},
a.rounded_xs,
]}
accessibilityLabel={alt}
@@ -120,7 +154,7 @@ export function ImageItem({
}
return (
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
@@ -129,6 +163,7 @@ export function ImageItem({
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
<Trans>Follows you</Trans>
</Text>
</View>
)
@@ -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} hasFixedHeight>
<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} />
}
@@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react'
import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -51,17 +51,19 @@ export const ExternalEmbed = ({
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
const onPress = useCallback(() => {
const onPress = () => {
playHaptic('Light')
onOpen?.()
}, [playHaptic, onOpen])
}
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
}
}, [link.uri, playHaptic])
const onShareExternal = IS_NATIVE
? () => {
if (link.uri) {
playHaptic('Heavy')
void shareUrl(link.uri)
}
}
: undefined
if (
embedPlayerParams?.source === 'tenor' ||
@@ -108,6 +110,7 @@ export const ExternalEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -0,0 +1,99 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
+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,116 @@
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}
hasFixedHeight>
<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>
)
}
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -80,13 +79,15 @@ export const StandardSiteEmbed = ({
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
const onLongPress = IS_NATIVE
? () => {
if (view.uri) {
playHaptic('Heavy')
void shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
: undefined
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
@@ -94,21 +95,17 @@ export const StandardSiteEmbed = ({
url: view.source?.uri || '',
})
}
const onLongPressPublication = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
useCallOnce(() => {
if (!preview) {
ax.metric('embed:standardSite:view', {url: view.uri})
}
})()
const onLongPressPublication = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
: undefined
if (isStandardPublication) {
return (
@@ -166,6 +163,7 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -355,6 +353,7 @@ export function PublicationCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_md,
@@ -385,7 +384,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>
@@ -425,6 +424,26 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
/*
* The custom site theme paints the button background with `accent` and the
* text with `accentForeground`. Only honor it when that pairing clears WCAG
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
* Otherwise we fall through to the default `secondary_inverted` styling,
* which is guaranteed to be legible.
*/
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
let useCustomTheme = false
if (accentRGB && accentForegroundRGB) {
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const ratio = utils.contrastRatio(accent, accentForeground)
useCustomTheme = ratio !== null && ratio >= 4.5
}
if (!view.source) return null
const publicationTitle = view.source.title
@@ -450,52 +469,60 @@ export function SubscribeButton({
}
}
const onLongPress = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
const onLongPress = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
}
}
}
: undefined
const button = (
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
)
if (!useCustomTheme) {
return button
}
return (
<StandardSiteThemeProvider view={view}>
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
</StandardSiteThemeProvider>
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
)
}
@@ -616,6 +643,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')}
+11 -6
View File
@@ -1,7 +1,8 @@
import {useWindowDimensions, View} from 'react-native'
import {View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -31,14 +32,16 @@ export function ProfileBadges({
interactive = false,
size,
style,
allowFontScaling = true,
}: ViewStyleProp & {
profile: bsky.profile.AnyProfileView
interactive?: boolean
size: Size
allowFontScaling?: boolean
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
const nativeScaleMultiplier = useNativeFontScale()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
@@ -48,10 +51,12 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm'
const verificationIconWidth =
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth =
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const scaleMultiplier = allowFontScaling
? nativeScaleMultiplier * alfScaleMultiplier
: 1
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
return (
<View
+3 -1
View File
@@ -23,6 +23,7 @@ export function Text({
title,
dataSet,
numberOfLines,
allowFontScaling = true,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
@@ -36,7 +37,7 @@ export function Text({
style,
],
{
fontScale: fonts.scaleMultiplier,
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
fontFamily: fonts.family,
flags,
},
@@ -57,6 +58,7 @@ export function Text({
numberOfLines,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
allowFontScaling,
...rest,
}
@@ -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>
)
@@ -667,7 +667,6 @@ function SearchInput({
/>
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={l`Search`}
value={value}
@@ -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>
+11
View File
@@ -11,6 +11,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
@@ -141,6 +142,15 @@ export function AddMembersFlow({
[memberListData],
)
const {data: chatStatus} = useChatActorStatusQuery()
const groupMemberLimit = chatStatus?.groupMemberLimit
// The existing members (including the viewer) already occupy slots, so the
// number of people that can still be added is whatever's left.
const remainingSlots =
groupMemberLimit !== undefined
? Math.max(0, groupMemberLimit - memberListData.length)
: undefined
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
groupChatDids: [],
groupChatProfiles: [],
@@ -471,6 +481,7 @@ export function AddMembersFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={remainingSlots}
label={l`Add group chat members`}
style={web([a.contents])}>
<Dialog.InnerFlatList
+103
View File
@@ -0,0 +1,103 @@
import {View} from 'react-native'
import {Plural, Trans} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {SimpleInlineLinkText} from '#/components/Link'
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, hasFixedHeight} = 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}
allowFontScaling={!hasFixedHeight}>
{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]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[
a.text_2xs,
a.leading_tight,
a.font_medium,
t.atoms.text_contrast_high,
]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<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]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
By{' '}
<SimpleInlineLinkText
to={makeProfileLink(preview.owner)}
label={ownerDisplayName}
style={[a.font_medium, t.atoms.text]}>
{ownerDisplayName}
</SimpleInlineLinkText>
</Trans>
</Text>
<ProfileBadges
profile={preview.owner}
size="sm"
allowFontScaling={!hasFixedHeight}
/>
<Text
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
{ownerHandle}
</Text>
</View>
</View>
</View>
)
}
+49
View File
@@ -0,0 +1,49 @@
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
/** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
hasFixedHeight: boolean
}
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, hasFixedHeight} = 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 allowFontScaling={!hasFixedHeight}>{action.label}</ButtonText>
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
</Button>
)
}
+146
View File
@@ -0,0 +1,146 @@
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,
hasFixedHeight,
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
hasFixedHeight: boolean
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, hasFixedHeight}}>
{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'
+32 -2
View File
@@ -10,8 +10,10 @@ import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
@@ -212,6 +214,7 @@ export function InitiateChatFlow({
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
const [searchText, setSearchText] = useState('')
@@ -458,6 +461,11 @@ export function InitiateChatFlow({
}
}, [])
const groupNameTooLong = isOverMaxGraphemeCount({
text: groupName,
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
})
let buttonLabel = l`Continue to group name`
let buttonText = l`Next`
let handleButtonPress = handlePressNext
@@ -470,7 +478,7 @@ export function InitiateChatFlow({
buttonText = l`Create`
handleButtonPress = handlePressConfirm
showButton = true
isButtonDisabled = groupName === ''
isButtonDisabled = groupName === '' || groupNameTooLong
break
}
@@ -564,7 +572,7 @@ export function InitiateChatFlow({
{chatState === ChatState.GROUP_NAME ? (
<View
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
<TextField.Root>
<TextField.Root isInvalid={groupNameTooLong}>
<TextField.Input
label={l`Group name`}
value={groupName}
@@ -573,6 +581,7 @@ export function InitiateChatFlow({
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="text"
clearButtonMode="while-editing"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
@@ -582,6 +591,20 @@ export function InitiateChatFlow({
}
/>
</TextField.Root>
{groupNameTooLong ? (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_semi_bold,
{color: t.palette.negative_400},
]}>
<Trans>
Group name is too long. The maximum number of characters
is {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
</Trans>
</Text>
) : null}
</View>
) : (
<UserSearchInput
@@ -622,6 +645,8 @@ export function InitiateChatFlow({
handleButtonPress,
buttonText,
groupName,
groupNameTooLong,
t.palette.negative_400,
searchText,
control,
showChatProfileTabs,
@@ -664,6 +689,11 @@ export function InitiateChatFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={
// groupMemberLimit counts the creator, who is added implicitly, so
// reserve one slot for them
groupMemberLimit ? groupMemberLimit - 1 : undefined
}
label={
chatState === ChatState.NEW_GROUP_CHAT
? l`Select group chat members`
+15 -5
View File
@@ -1,7 +1,9 @@
import {ChatBskyConvoLeaveConvo} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {type DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
@@ -30,10 +32,18 @@ export function LeaveConvoPrompt({
)
}
},
onError: () => {
Toast.show(l`Could not leave chat`, {
type: 'error',
})
onError: error => {
let errorMessage = l`Could not leave chat`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (error instanceof ChatBskyConvoLeaveConvo.InvalidConvoError) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyConvoLeaveConvo.OwnerCannotLeaveError
) {
errorMessage = l`Owner must lock the group before leaving.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
@@ -43,7 +53,7 @@ export function LeaveConvoPrompt({
title={l`Leave conversation`}
description={
hasMessages
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participants.`
: l`Are you sure you want to leave this conversation?`
}
confirmButtonCta={l`Leave`}
+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,88 @@
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}
hasFixedHeight={false}>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
</ChatInvite.Root>
</View>
</View>
</MessageContextProvider>
)
}
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
export {MessageItemInviteEmbed}
+18 -2
View File
@@ -125,6 +125,22 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
[openDeleteMessage, openReportMessage, openReactions],
)
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
// live message out of the convo items so optimistic reaction changes (e.g.
// "Tap to remove") are reflected in the dialog without closing it first.
const reactionsMessage = useMemo(() => {
if (!reactionsTarget) return null
for (const item of convo.items) {
if (
(item.type === 'message' || item.type === 'pending-message') &&
item.message.id === reactionsTarget.id
) {
return item.message
}
}
return reactionsTarget
}, [convo.items, reactionsTarget])
const reportSubject = reportTarget
? ({
view: 'message',
@@ -153,11 +169,11 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsTarget && (
{reactionsMessage && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsTarget}
message={reactionsMessage}
onClose={() => setReactionsTarget(null)}
/>
)}
@@ -34,32 +34,40 @@ export function GroupChatProfileCard({
name={profile.did}
label={displayName}
style={[a.flex_1, a.py_sm, a.px_lg]}>
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
{({disabled, selected}) => (
<>
<View
style={[
a.flex_grow,
!enabled || (disabled && !selected) ? {opacity: 0.5} : null,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
{enabled ? <Toggle.Checkbox /> : null}
</>
)}
</Toggle.Item>
)
}
+16
View File
@@ -47,6 +47,22 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
}
}
/**
* Resolves the effective `allowGroupInvites` value for a chat declaration.
* When unset, group invites follow the general DM preference
* (`allowIncoming`), which itself defaults to `following`. This mirrors the
* `undefined` fallthrough in canBeAddedToGroup, and is the single source of
* truth for both displaying and persisting the setting.
*/
export function resolveAllowGroupInvites(
chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
): 'all' | 'none' | 'following' {
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
| 'all'
| 'none'
| 'following'
}
export function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
+1
View File
@@ -142,6 +142,7 @@ export function AutoSizedImage({
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder />
+39 -1
View File
@@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {IS_ANDROID, IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
@@ -264,6 +264,9 @@ export function Gallery({
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
// Disable Android's stretch overscroll, which can leave the carousel
// settled just off the left edge instead of aligned to x = 0
overScrollMode={IS_ANDROID ? 'never' : 'auto'}
showsHorizontalScrollIndicator={false}
directionalLockEnabled
nestedScrollEnabled
@@ -340,6 +343,11 @@ export function Gallery({
marginLeft: -insetLeft,
width,
},
// Prevent horizontal trackpad/wheel swipes from triggering the
// browser's back/forward overscroll-navigation gesture. Handles
// Chrome and Firefox; Safari is handled via the wheel listener in
// usePointerHandlers.web.ts since it ignores overscroll-behavior.
web({overscrollBehaviorX: 'contain'}),
]}
contentContainerStyle={{
gap: ITEM_GAP,
@@ -480,8 +488,38 @@ function GalleryImage({
height: e.source.height,
})
}}
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
top: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
@@ -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

Some files were not shown because too many files have changed in this diff Show More