Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e0ebcefd2 | |||
| e2378f8614 | |||
| 54438cd26e | |||
| e245d760cf | |||
| a930e5832c | |||
| b119c36229 | |||
| 0c8faaacd6 | |||
| d8ca8525b9 | |||
| a1eb8f9765 | |||
| a6ad6e8da4 | |||
| e3ba1c7c4c | |||
| 7577933378 | |||
| b9cd7e992a | |||
| a253e877fc | |||
| 11dd259716 | |||
| 0cc6d49388 | |||
| 5eeb80fe96 | |||
| a675cbacb6 | |||
| 1d5fb89c80 | |||
| ac63ee7739 | |||
| a5338090cb | |||
| fb8316fd85 | |||
| b236f274c3 | |||
| 2e9bfd363c | |||
| 32c0c0ec83 | |||
| 6d468c8ca4 | |||
| a5aa985b33 | |||
| fd8df7e201 | |||
| f8aae4a192 | |||
| 67ed59fbcd | |||
| 7d4d7642fd | |||
| f81bb6f494 | |||
| 3cdc0604ca | |||
| d3f073b3d2 | |||
| 96e321dfd9 | |||
| 68da797180 | |||
| 2f61c0a7b4 | |||
| f84c85aac6 | |||
| 144ba30ea6 | |||
| de926d1838 | |||
| 45bd5b45e8 | |||
| 923e1635d8 | |||
| ded2ca3744 | |||
| 09fa93553b | |||
| b9637c0305 | |||
| 6677ae977c | |||
| 58783ca5ae | |||
| d3c9ed027c | |||
| 795d493dea | |||
| 66db349ef2 | |||
| 08cd4e58b0 | |||
| c79b5bfa08 | |||
| 49f8e5031f | |||
| 9f200aab05 | |||
| a19d652a39 | |||
| ad4591eb29 | |||
| 153772b760 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
|
||||
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||
import {
|
||||
downloadAndResize,
|
||||
type DownloadAndResizeOpts,
|
||||
getResizedDimensions,
|
||||
} from '../../src/lib/media/manip'
|
||||
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||
|
||||
const mockResizedImage = {
|
||||
path: 'file://resized-image.jpg',
|
||||
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
|
||||
|
||||
// First time it gets called is to get dimensions
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
||||
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||
// downsized.
|
||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[{resize: {height: opts.height, width: opts.width}}],
|
||||
[{resize: {height: 100, width: 100}}],
|
||||
{format: SaveFormat.JPEG, compress: 1.0},
|
||||
)
|
||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
|
||||
it('should return undefined for invalid URI', async () => {
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'invalid-uri',
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxDimension: 2000,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
|
||||
width: 1200,
|
||||
height: 1000,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 1000,
|
||||
height: 1200,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
||||
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
||||
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
|
||||
width: 3000,
|
||||
height: 1500,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 2000,
|
||||
height: 4000,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual({
|
||||
width: 2000,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
getChatInviteCodeFromUrl,
|
||||
isPossiblyAUrl,
|
||||
isTrustedUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
|
||||
expect(output).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatInviteCodeFromUrl', () => {
|
||||
type Case = [string, string | undefined]
|
||||
|
||||
const cases: Case[] = [
|
||||
['https://bsky.app/c/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
|
||||
// http is not recognized as a bsky.app url
|
||||
['http://bsky.app/c/abcdefg', undefined],
|
||||
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
|
||||
['/c/abcdefg', 'abcdefg'],
|
||||
['/c/abcdefg?utm=foo', 'abcdefg'],
|
||||
['/c/abcdefg#section', 'abcdefg'],
|
||||
|
||||
// too short
|
||||
['https://bsky.app/c/abcdef', undefined],
|
||||
['/c/abcdef', undefined],
|
||||
// too long
|
||||
['https://bsky.app/c/abcdefghijk', undefined],
|
||||
['/c/abcdefghijk', undefined],
|
||||
// invalid characters
|
||||
['https://bsky.app/c/abc-def', undefined],
|
||||
['/c/abc def', undefined],
|
||||
// trailing path
|
||||
['https://bsky.app/c/abcdefg/extra', undefined],
|
||||
['/c/abcdefg/extra', undefined],
|
||||
// wrong path
|
||||
['https://bsky.app/profile/abcdefg', undefined],
|
||||
['https://bsky.app/c', undefined],
|
||||
// wrong host
|
||||
['https://example.com/c/abcdefg', undefined],
|
||||
// not a url, not a path
|
||||
['c/abcdefg', undefined],
|
||||
['abcdefg', undefined],
|
||||
['', undefined],
|
||||
// malformed url
|
||||
['https://[invalid/c/abcdefg', undefined],
|
||||
]
|
||||
|
||||
it.each(cases)('given input %p, returns %p', (input, expected) => {
|
||||
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -349,7 +349,7 @@ module.exports = function (_config) {
|
||||
},
|
||||
],
|
||||
[
|
||||
'@mozzius/expo-dynamic-app-icon',
|
||||
'@bsky.app/expo-dynamic-app-icon',
|
||||
{
|
||||
/**
|
||||
* Default set
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
}
|
||||
},
|
||||
"src/analytics/PassiveAnalytics.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -124,11 +121,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Button.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Composer/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -261,11 +253,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+66
-24
@@ -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
@@ -93,11 +93,12 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.8",
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
@@ -123,7 +124,6 @@
|
||||
"@ipld/dag-cbor": "^9.2.7",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
|
||||
Generated
+23
-23
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.8
|
||||
version: 0.20.8
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -256,6 +256,9 @@ importers:
|
||||
'@bsky.app/alf':
|
||||
specifier: ^0.1.14
|
||||
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.5
|
||||
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-guess-language':
|
||||
specifier: ^0.2.8
|
||||
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -331,9 +334,6 @@ importers:
|
||||
'@lingui/react':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
|
||||
'@mozzius/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1624,6 +1624,13 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5':
|
||||
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8':
|
||||
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
|
||||
peerDependencies:
|
||||
@@ -2327,13 +2334,6 @@ packages:
|
||||
'@messageformat/parser@5.1.1':
|
||||
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1':
|
||||
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
|
||||
peerDependencies:
|
||||
expo: ^52 || ^53 || ^54
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -9493,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
'@atproto/api@0.20.9':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10446,6 +10446,14 @@ snapshots:
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-responsive: 10.0.1(react@19.1.0)
|
||||
|
||||
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -11449,14 +11457,6 @@ snapshots:
|
||||
dependencies:
|
||||
moo: 0.5.3
|
||||
|
||||
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.12
|
||||
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
xcode: 3.0.1
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
|
||||
})
|
||||
}
|
||||
|
||||
if (IS_DEV || IS_TESTFLIGHT) {
|
||||
const feats = Object.values(Features).reduce(
|
||||
(acc, feat) => {
|
||||
acc[feat] = features.evalFeature(feat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<Features, any>,
|
||||
)
|
||||
ax.logger.info('FEATURES', {
|
||||
features: feats,
|
||||
definitions: features.getFeatures(),
|
||||
})
|
||||
}
|
||||
// if (IS_DEV || IS_TESTFLIGHT) {
|
||||
// const feats = Object.values(Features).reduce(
|
||||
// (acc, feat) => {
|
||||
// acc[feat] = features.evalFeature(feat)
|
||||
// return acc
|
||||
// },
|
||||
// {} as Record<Features, any>,
|
||||
// )
|
||||
// ax.logger.info('FEATURES', {
|
||||
// features: feats,
|
||||
// definitions: features.getFeatures(),
|
||||
// })
|
||||
// }
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
|
||||
}
|
||||
|
||||
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
||||
logger.debug(`sendBatch: ${events.length}`, {
|
||||
isRetry,
|
||||
})
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({events})
|
||||
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export type ButtonColor =
|
||||
| 'negative'
|
||||
| 'primary_subtle'
|
||||
| 'negative_subtle'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
variant: variantProp,
|
||||
color,
|
||||
size,
|
||||
shape = 'default',
|
||||
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
* If a `color` is set, then we want to use the existing codepaths for
|
||||
* "solid" buttons. This is to maintain backwards compatibility.
|
||||
*/
|
||||
if (!variant && color) {
|
||||
let variant: VariantProps['variant'] = variantProp
|
||||
if (!variantProp && color) {
|
||||
variant = 'solid'
|
||||
}
|
||||
|
||||
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
paddingHorizontal: 24,
|
||||
gap: 6,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 28,
|
||||
gap: 5,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 8,
|
||||
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
borderRadius: 10,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 8,
|
||||
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
} else {
|
||||
baseStyles.push({height: 44, width: 44})
|
||||
}
|
||||
} else if (size === 'medium') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
} else {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.font_medium)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'tiny') {
|
||||
@@ -799,6 +821,7 @@ export function ButtonIcon({
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
medium: 'sm',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
@@ -828,6 +851,7 @@ export function ButtonIcon({
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
medium: 17,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
@@ -841,6 +865,7 @@ export function ButtonIcon({
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
medium: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -499,6 +499,7 @@ function TriggerClone({
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={_(msg`The subject of the context menu`)}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
cachePolicy="none"
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
@@ -226,17 +226,21 @@ function LightboxGallery({
|
||||
)}
|
||||
</View>
|
||||
{img.alt ? (
|
||||
<View
|
||||
<ScrollView
|
||||
// Cap the overlay height so long alt text scrolls within the overlay
|
||||
// instead of growing past the top of the screen and pushing the image
|
||||
// out of view. Only scrollable once expanded.
|
||||
style={[
|
||||
a.px_4xl,
|
||||
a.py_2xl,
|
||||
styles.altScroll,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
// @ts-expect-error web only
|
||||
backdropFilter: 'blur(16px)',
|
||||
},
|
||||
delayedFadeInAnim,
|
||||
]}>
|
||||
]}
|
||||
scrollEnabled={isAltExpanded}
|
||||
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
|
||||
<Pressable
|
||||
accessibilityLabel={l`Expand alt text`}
|
||||
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
|
||||
@@ -250,7 +254,7 @@ function LightboxGallery({
|
||||
{img.alt}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{imgs.length > 1 && (
|
||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
altScroll: {
|
||||
// Size to content like the View it replaced, rather than filling the
|
||||
// column via ScrollView's default flexGrow.
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
// @ts-ignore web-only -sfn
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
menuBtn: {
|
||||
top: 20,
|
||||
left: 20,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {useRef} from 'react'
|
||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {height: screenHeight} = useSafeAreaFrame()
|
||||
const isMomentumScrolling = useRef(false)
|
||||
|
||||
if (!altText) return null
|
||||
|
||||
// Cap the overlay height so long alt text - or text enlarged by the OS via
|
||||
// Dynamic Type / font scaling - scrolls within the overlay instead of growing
|
||||
// past the top of the screen. Leaves the upper half clear for the header.
|
||||
const maxHeight = screenHeight / 2
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -46,6 +55,7 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
}),
|
||||
]}>
|
||||
<ScrollView
|
||||
style={{maxHeight}}
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {BlurView} from 'expo-blur'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
@@ -14,26 +13,38 @@ const GAP = 5
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<View style={styles.root}>
|
||||
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</BlurView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
root: {
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
const ACTIVE = 6
|
||||
const INACTIVE = 4
|
||||
const GAP = 5
|
||||
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
// @ts-expect-error web-only
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
height: ACTIVE,
|
||||
borderRadius: ACTIVE / 2,
|
||||
},
|
||||
inactiveDot: {
|
||||
width: INACTIVE,
|
||||
height: INACTIVE,
|
||||
borderRadius: INACTIVE / 2,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
inactive: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
})
|
||||
@@ -246,6 +246,7 @@ const ImageItem = ({
|
||||
}
|
||||
}
|
||||
cachePolicy="memory"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -32,6 +32,7 @@ import Animated, {
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
@@ -136,6 +137,9 @@ export default function ImageViewRoot({
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
@@ -47,6 +51,34 @@ export function Embed({
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'gallery') {
|
||||
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
|
||||
// a 10-image gallery doesn't blow out the row width. Single pass instead
|
||||
// of filter().slice().map() so we stop at 4 viewable items rather than
|
||||
// walking every item in a 10-image gallery.
|
||||
const tiles: React.ReactNode[] = []
|
||||
for (const item of e.view.items) {
|
||||
if (tiles.length >= 4) break
|
||||
if (!AppBskyEmbedGallery.isViewImage(item)) continue
|
||||
if (peekable) {
|
||||
const image: AppBskyEmbedImages.ViewImage = {
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
|
||||
} else {
|
||||
tiles.push(
|
||||
<ImageItem
|
||||
key={item.thumbnail}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
return <Outer style={style}>{tiles}</Outer>
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isGifEmbed(e.view.external.uri)) return null
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {Card} from './Card'
|
||||
export {
|
||||
type ChatInviteAction,
|
||||
type ChatInviteContextValue,
|
||||
useChatInvite,
|
||||
} from './Context'
|
||||
export {JoinButton} from './JoinButton'
|
||||
export {Root} from './Root'
|
||||
@@ -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`
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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}
|
||||
@@ -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} can’t 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} can’t be added</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
</>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -142,6 +142,7 @@ export function AutoSizedImage({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
|
||||
onPressIn?: (index: number) => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
}
|
||||
|
||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
||||
export function ImageLayoutGrid({
|
||||
style,
|
||||
isWithinQuote: isWithinQuoteProp,
|
||||
...props
|
||||
}: ImageLayoutGridProps) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const gap =
|
||||
const isWithinQuote =
|
||||
isWithinQuoteProp ??
|
||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? gtMobile
|
||||
? a.gap_xs
|
||||
: a.gap_2xs
|
||||
: a.gap_xs
|
||||
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
|
||||
<ImageLayoutGridInner {...props} gap={gap} />
|
||||
<ImageLayoutGridInner
|
||||
{...props}
|
||||
gap={gap}
|
||||
isWithinQuote={isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
gap: {gap: number}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Props {
|
||||
onPressIn?: EventFunction
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
insetBorderStyle?: StyleProp<ViewStyle>
|
||||
containerRefs: AnimatedRef<any>[]
|
||||
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
||||
@@ -42,6 +43,7 @@ export function GalleryItem({
|
||||
onPressIn,
|
||||
onLongPress,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
@@ -52,6 +54,7 @@ export function GalleryItem({
|
||||
const image = images[index]
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
isWithinQuote ??
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
|
||||
const aspect =
|
||||
@@ -106,6 +109,7 @@ export function GalleryItem({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
|
||||
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
||||
codes: code ? [code] : undefined,
|
||||
hasSession,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
||||
@@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
) {
|
||||
errorMessage = l`The member limit has been reached.`
|
||||
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
|
||||
errorMessage = l`You have been removed from this group.`
|
||||
errorMessage = l`You have been previously removed from this group and can’t join it using this link.`
|
||||
}
|
||||
Toast.show(errorMessage)
|
||||
},
|
||||
@@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
<View
|
||||
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.mb_2xs,
|
||||
a.text_center,
|
||||
@@ -402,7 +404,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
color="primary"
|
||||
disabled={!code}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>Open chat</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Open chat</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ArrowRightIcon} />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
}
|
||||
accessibilityHint={
|
||||
joinLinkPreview.requireApproval
|
||||
? l`Request access to join this group chat`
|
||||
: l`Join this group chat`
|
||||
? l`Tap to request access to join this group chat`
|
||||
: l`Tap to join this group chat immediately`
|
||||
}
|
||||
size="large"
|
||||
color={buttonColor}
|
||||
|
||||
@@ -87,7 +87,10 @@ export function parseReportSubject(
|
||||
reply: !!record.reply,
|
||||
image:
|
||||
embed.type === 'images' ||
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
|
||||
embed.type === 'gallery' ||
|
||||
(embed.type === 'post_with_media' &&
|
||||
(embed.media.type === 'images' ||
|
||||
embed.media.type === 'gallery')),
|
||||
video:
|
||||
embed.type === 'video' ||
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
||||
|
||||
@@ -87,6 +87,7 @@ function Inner({
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -54,6 +54,7 @@ export function LinkPreview({
|
||||
contentFit="cover"
|
||||
onLoad={() => setImageLoadError(false)}
|
||||
onError={() => setImageLoadError(true)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
)}
|
||||
{linkMeta && (!linkMeta.image || imageLoadError) && (
|
||||
|
||||
@@ -147,6 +147,7 @@ export function LiveStatus({
|
||||
contentFit="cover"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<LiveIndicator
|
||||
size="large"
|
||||
|
||||
+52
-3
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedExternal,
|
||||
type AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyEmbedRecord,
|
||||
type AppBskyEmbedRecordWithMedia,
|
||||
@@ -21,6 +22,7 @@ import {sha256} from 'js-sha256'
|
||||
import {CID} from 'multiformats/cid'
|
||||
import * as Hasher from 'multiformats/hashes/hasher'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
@@ -177,7 +179,8 @@ export async function post(
|
||||
writes: writes,
|
||||
validate: true,
|
||||
})
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.error(`Failed to create post`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
@@ -252,6 +255,7 @@ async function resolveEmbed(
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
@@ -311,6 +315,7 @@ async function resolveMedia(
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| undefined
|
||||
> {
|
||||
@@ -323,7 +328,10 @@ async function resolveMedia(
|
||||
const images: AppBskyEmbedImages.Image[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
const {path, width, height, mime} = await compressImage(
|
||||
image,
|
||||
IMAGE_SIZE_CONFIG_POSTS,
|
||||
)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
@@ -338,6 +346,34 @@ async function resolveMedia(
|
||||
images,
|
||||
}
|
||||
}
|
||||
if (embedDraft.media?.type === 'gallery') {
|
||||
const imagesDraft = embedDraft.media.images
|
||||
logger.debug(`Uploading images`, {
|
||||
count: imagesDraft.length,
|
||||
})
|
||||
onStateChange?.(t`Uploading images...`)
|
||||
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(
|
||||
image,
|
||||
IMAGE_SIZE_CONFIG_POSTS,
|
||||
)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
$type: 'app.bsky.embed.gallery#image' as const,
|
||||
image: res.data.blob,
|
||||
alt: image.alt,
|
||||
aspectRatio: {width, height},
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
$type: 'app.bsky.embed.gallery',
|
||||
items,
|
||||
}
|
||||
}
|
||||
if (
|
||||
embedDraft.media?.type === 'video' &&
|
||||
embedDraft.media.video.status === 'done'
|
||||
@@ -427,6 +463,16 @@ async function resolveMedia(
|
||||
},
|
||||
}
|
||||
}
|
||||
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
|
||||
return {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: resolvedLink.uri,
|
||||
title: resolvedLink.view.name,
|
||||
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -470,6 +516,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
|
||||
}
|
||||
|
||||
// Returns a transformed version of the object for use in DAG-CBOR.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function prepareForHashing(v: any): any {
|
||||
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
|
||||
// the API client will convert this for you but we're hashing in the client,
|
||||
@@ -492,9 +539,10 @@ function prepareForHashing(v: any): any {
|
||||
|
||||
// Walk through plain objects
|
||||
if (isPlainObject(v)) {
|
||||
const obj: any = {}
|
||||
const obj: Record<string, unknown> = {}
|
||||
let pure = true
|
||||
for (const key in v) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
let value = v[key]
|
||||
// `value` is undefined
|
||||
if (value === undefined) {
|
||||
@@ -513,6 +561,7 @@ function prepareForHashing(v: any): any {
|
||||
return v
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isPlainObject(v: any): boolean {
|
||||
if (typeof v !== 'object' || v === null) {
|
||||
return false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user