Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8eac442b1 |
@@ -20,7 +20,6 @@ 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
|
||||
@@ -38,7 +37,6 @@ 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,7 +57,11 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Lint checks
|
||||
@@ -95,7 +99,11 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Run tests
|
||||
|
||||
@@ -26,7 +26,11 @@ jobs:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
|
||||
@@ -34,8 +34,12 @@ jobs:
|
||||
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
|
||||
|
||||
- name: pnpm install
|
||||
# Fine to skip scripts since we don't run any code
|
||||
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
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
|
||||
|
||||
- name: Verify pnpm-lock.yaml
|
||||
run: |
|
||||
|
||||
@@ -133,4 +133,3 @@ bskyweb/static/media/*.svg
|
||||
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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',
|
||||
@@ -42,8 +41,10 @@ describe('downloadAndResize', () => {
|
||||
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'https://example.com/image.jpg',
|
||||
maxDimension: 2000,
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -59,11 +60,9 @@ 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: 100, width: 100}}],
|
||||
[{resize: {height: opts.height, width: opts.width}}],
|
||||
{format: SaveFormat.JPEG, compress: 1.0},
|
||||
)
|
||||
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
|
||||
@@ -74,8 +73,10 @@ describe('downloadAndResize', () => {
|
||||
it('should return undefined for invalid URI', async () => {
|
||||
const opts: DownloadAndResizeOpts = {
|
||||
uri: 'invalid-uri',
|
||||
maxDimension: 2000,
|
||||
width: 100,
|
||||
height: 100,
|
||||
maxSize: 500000,
|
||||
mode: 'cover',
|
||||
timeout: 10000,
|
||||
}
|
||||
|
||||
@@ -89,19 +90,13 @@ describe('downloadAndResize', () => {
|
||||
width: 1200,
|
||||
height: 1000,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 1000,
|
||||
height: 1200,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
|
||||
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
|
||||
@@ -112,19 +107,13 @@ describe('downloadAndResize', () => {
|
||||
width: 3000,
|
||||
height: 1500,
|
||||
}
|
||||
const resizedDimensionsOne = getResizedDimensions(
|
||||
initialDimensionsOne,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
|
||||
|
||||
const initialDimensionsTwo = {
|
||||
width: 2000,
|
||||
height: 4000,
|
||||
}
|
||||
const resizedDimensionsTwo = getResizedDimensions(
|
||||
initialDimensionsTwo,
|
||||
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
|
||||
)
|
||||
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
|
||||
|
||||
expect(resizedDimensionsOne).toEqual({
|
||||
width: 2000,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
getChatInviteCodeFromUrl,
|
||||
isPossiblyAUrl,
|
||||
isTrustedUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -179,47 +178,3 @@ 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) {
|
||||
},
|
||||
],
|
||||
[
|
||||
'@bsky.app/expo-dynamic-app-icon',
|
||||
'@mozzius/expo-dynamic-app-icon',
|
||||
{
|
||||
/**
|
||||
* Default set
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
}
|
||||
},
|
||||
"src/analytics/PassiveAnalytics.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -121,6 +124,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Button.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Composer/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -253,6 +261,11 @@
|
||||
"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
|
||||
@@ -261,6 +274,11 @@
|
||||
"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
|
||||
@@ -793,6 +811,14 @@
|
||||
"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
|
||||
@@ -1346,6 +1372,9 @@
|
||||
}
|
||||
},
|
||||
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 5
|
||||
}
|
||||
|
||||
+24
-66
@@ -6,7 +6,6 @@ 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
|
||||
@@ -14,8 +13,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
|
||||
private const val TAG = "ExpoReceiveAndroidIntents"
|
||||
|
||||
enum class AttachmentType {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
@@ -122,15 +119,17 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
) {
|
||||
// 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) }
|
||||
var allParams = ""
|
||||
|
||||
if (allParams.isEmpty()) return
|
||||
uris.forEachIndexed { index, uri ->
|
||||
val info = getImageInfo(uri)
|
||||
val params = buildUriData(info)
|
||||
allParams = "${allParams}$params"
|
||||
|
||||
if (index < uris.count() - 1) {
|
||||
allParams = "$allParams,"
|
||||
}
|
||||
}
|
||||
|
||||
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
|
||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||
@@ -159,30 +158,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
val file = createFile(extension)
|
||||
|
||||
// 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 out = FileOutputStream(file)
|
||||
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
|
||||
it.copyTo(out)
|
||||
}
|
||||
|
||||
val info =
|
||||
getVideoInfo(uri) ?: run {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
val info = getVideoInfo(uri) ?: return
|
||||
|
||||
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
|
||||
|
||||
@@ -195,29 +176,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private fun getImageInfo(uri: Uri): Map<String, Any> {
|
||||
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
||||
// 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")
|
||||
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
|
||||
}
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
|
||||
return mapOf(
|
||||
"width" to bitmap.width,
|
||||
@@ -228,19 +195,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
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()
|
||||
}
|
||||
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()
|
||||
|
||||
if (width == null || height == null) {
|
||||
return null
|
||||
|
||||
+2
-2
@@ -93,12 +93,11 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/api": "0.20.8",
|
||||
"@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",
|
||||
@@ -124,6 +123,7 @@
|
||||
"@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.9
|
||||
version: 0.20.9
|
||||
specifier: 0.20.8
|
||||
version: 0.20.8
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -256,9 +256,6 @@ 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)
|
||||
@@ -334,6 +331,9 @@ 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.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
'@atproto/api@0.20.8':
|
||||
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1624,13 +1624,6 @@ 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:
|
||||
@@ -2334,6 +2327,13 @@ 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.9':
|
||||
'@atproto/api@0.20.8':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
@@ -10446,14 +10446,6 @@ 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)
|
||||
@@ -11457,6 +11449,14 @@ 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
|
||||
|
||||
+1
-7
@@ -9,12 +9,7 @@ import {
|
||||
setFontScale as persistFontScale,
|
||||
} from '#/alf/fonts'
|
||||
import {themes} from '#/alf/themes'
|
||||
import {
|
||||
contrastRatio,
|
||||
darken,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||
@@ -31,7 +26,6 @@ export const utils = {
|
||||
rgbToHex,
|
||||
lighten,
|
||||
darken,
|
||||
contrastRatio,
|
||||
}
|
||||
|
||||
export type Alf = {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
contrastRatio,
|
||||
darken,
|
||||
hexToRgb,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
} from './colorGeneration'
|
||||
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
|
||||
|
||||
describe('hexToRgb', () => {
|
||||
it('parses 6-digit hex', () => {
|
||||
@@ -98,33 +92,3 @@ 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,48 +72,6 @@ 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,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,6 +2,8 @@ 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.
|
||||
@@ -25,19 +27,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,6 +67,10 @@ 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' | 'medium' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant: variantProp,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
shape = 'default',
|
||||
@@ -160,8 +160,7 @@ 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.
|
||||
*/
|
||||
let variant: VariantProps['variant'] = variantProp
|
||||
if (!variantProp && color) {
|
||||
if (!variant && color) {
|
||||
variant = 'solid'
|
||||
}
|
||||
|
||||
@@ -459,12 +458,6 @@ 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,
|
||||
@@ -486,13 +479,6 @@ 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,
|
||||
@@ -519,12 +505,6 @@ 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})
|
||||
@@ -778,8 +758,6 @@ 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') {
|
||||
@@ -821,7 +799,6 @@ export function ButtonIcon({
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
medium: 'sm',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
@@ -851,7 +828,6 @@ export function ButtonIcon({
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
medium: 17,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
@@ -865,7 +841,6 @@ export function ButtonIcon({
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
medium: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
@@ -499,7 +499,6 @@ 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, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {Pressable, 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,21 +226,17 @@ function LightboxGallery({
|
||||
)}
|
||||
</View>
|
||||
{img.alt ? (
|
||||
<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.
|
||||
<View
|
||||
style={[
|
||||
styles.altScroll,
|
||||
a.px_4xl,
|
||||
a.py_2xl,
|
||||
{
|
||||
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`}
|
||||
@@ -254,7 +250,7 @@ function LightboxGallery({
|
||||
{img.alt}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
{imgs.length > 1 && (
|
||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||
@@ -453,14 +449,6 @@ 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,9 +1,6 @@
|
||||
import {useRef} from 'react'
|
||||
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -20,16 +17,10 @@ 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={[
|
||||
@@ -55,7 +46,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
}),
|
||||
]}>
|
||||
<ScrollView
|
||||
style={{maxHeight}}
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {BlurView} from 'expo-blur'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
@@ -13,38 +14,26 @@ const GAP = 5
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
row: {
|
||||
gap: GAP,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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,7 +246,6 @@ const ImageItem = ({
|
||||
}
|
||||
}
|
||||
cachePolicy="memory"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -32,7 +32,6 @@ 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'
|
||||
@@ -137,9 +136,6 @@ export default function ImageViewRoot({
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
@@ -51,34 +47,6 @@ 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
|
||||
@@ -127,12 +95,10 @@ export function ImageItem({
|
||||
thumbnail,
|
||||
alt,
|
||||
children,
|
||||
maxWidth = 100,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
maxWidth?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -143,7 +109,7 @@ export function ImageItem({
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth},
|
||||
{maxWidth: 100},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
@@ -154,7 +120,7 @@ export function ImageItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}>
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<Image
|
||||
key={thumbnail}
|
||||
source={{uri: thumbnail}}
|
||||
@@ -163,7 +129,6 @@ 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>
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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 {useMemo} from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
@@ -51,19 +51,17 @@ export const ExternalEmbed = ({
|
||||
}, [link.uri, externalEmbedPrefs])
|
||||
const hasMedia = Boolean(imageUri || embedPlayerParams)
|
||||
|
||||
const onPress = () => {
|
||||
const onPress = useCallback(() => {
|
||||
playHaptic('Light')
|
||||
onOpen?.()
|
||||
}
|
||||
}, [playHaptic, onOpen])
|
||||
|
||||
const onShareExternal = IS_NATIVE
|
||||
? () => {
|
||||
if (link.uri) {
|
||||
playHaptic('Heavy')
|
||||
void shareUrl(link.uri)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (link.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(link.uri)
|
||||
}
|
||||
}, [link.uri, playHaptic])
|
||||
|
||||
if (
|
||||
embedPlayerParams?.source === 'tenor' ||
|
||||
@@ -110,7 +108,6 @@ export const ExternalEmbed = ({
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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,7 +2,6 @@ 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'
|
||||
@@ -16,29 +15,16 @@ 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'> | EmbedType<'gallery'>
|
||||
embed: EmbedType<'images'>
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {openLightbox} = useLightboxControls()
|
||||
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)
|
||||
const {images} = embed.view
|
||||
const galleryEnabled = 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.
|
||||
@@ -123,7 +109,7 @@ export function ImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
if (useExpandedLayout) {
|
||||
if (galleryEnabled) {
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<Gallery
|
||||
@@ -144,7 +130,6 @@ export function ImageEmbed({
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
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,6 +5,7 @@ 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'
|
||||
@@ -79,15 +80,13 @@ export const StandardSiteEmbed = ({
|
||||
onEmbedInteractionCallback?.()
|
||||
ax.metric('embed:standardSite:article:press', {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 onLongPress = () => {
|
||||
if (view.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(view.uri)
|
||||
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
|
||||
}
|
||||
}
|
||||
const onPressPublication = () => {
|
||||
playHaptic('Light')
|
||||
onEmbedInteractionCallback?.()
|
||||
@@ -95,17 +94,21 @@ export const StandardSiteEmbed = ({
|
||||
url: view.source?.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
|
||||
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})
|
||||
}
|
||||
})()
|
||||
|
||||
if (isStandardPublication) {
|
||||
return (
|
||||
@@ -163,7 +166,6 @@ export const StandardSiteEmbed = ({
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -353,7 +355,6 @@ export function PublicationCard({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -384,7 +385,7 @@ export function PublicationCard({
|
||||
<View style={[a.pointer_events_none]}>
|
||||
{view.description && (
|
||||
<View style={[a.pt_sm]}>
|
||||
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
|
||||
{view.description}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -424,26 +425,6 @@ 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
|
||||
@@ -469,60 +450,52 @@ export function SubscribeButton({
|
||||
}
|
||||
}
|
||||
|
||||
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 || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
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 || '',
|
||||
})
|
||||
}
|
||||
: 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}>{button}</StandardSiteThemeProvider>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,7 +616,6 @@ export function PublicationFooter({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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'
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
type EmbedType,
|
||||
parseEmbed,
|
||||
} from '#/types/bsky/post'
|
||||
import {ChatInviteEmbed} from './ChatInviteEmbed'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
@@ -54,7 +52,6 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
||||
|
||||
switch (embed.type) {
|
||||
case 'images':
|
||||
case 'gallery':
|
||||
case 'link':
|
||||
case 'video': {
|
||||
return <MediaEmbed embed={embed} {...rest} />
|
||||
@@ -90,8 +87,7 @@ function MediaEmbed({
|
||||
embed: TEmbed
|
||||
}) {
|
||||
switch (embed.type) {
|
||||
case 'images':
|
||||
case 'gallery': {
|
||||
case 'images': {
|
||||
return (
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
@@ -114,21 +110,6 @@ 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,8 +1,7 @@
|
||||
import {View} from 'react-native'
|
||||
import {useWindowDimensions, 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'
|
||||
@@ -32,16 +31,14 @@ 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 nativeScaleMultiplier = useNativeFontScale()
|
||||
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
|
||||
const {
|
||||
fonts: {scaleMultiplier: alfScaleMultiplier},
|
||||
} = useAlf()
|
||||
@@ -51,12 +48,10 @@ export function ProfileBadges({
|
||||
|
||||
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
||||
|
||||
const scaleMultiplier = allowFontScaling
|
||||
? nativeScaleMultiplier * alfScaleMultiplier
|
||||
: 1
|
||||
|
||||
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
|
||||
const botIconWidth = botIconSizes[size] * scaleMultiplier
|
||||
const verificationIconWidth =
|
||||
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
||||
const botIconWidth =
|
||||
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -23,7 +23,6 @@ export function Text({
|
||||
title,
|
||||
dataSet,
|
||||
numberOfLines,
|
||||
allowFontScaling = true,
|
||||
...rest
|
||||
}: TextProps) {
|
||||
const {fonts, flags} = useAlf()
|
||||
@@ -37,7 +36,7 @@ export function Text({
|
||||
style,
|
||||
],
|
||||
{
|
||||
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
|
||||
fontScale: fonts.scaleMultiplier,
|
||||
fontFamily: fonts.family,
|
||||
flags,
|
||||
},
|
||||
@@ -58,7 +57,6 @@ export function Text({
|
||||
numberOfLines,
|
||||
style: s,
|
||||
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
||||
allowFontScaling,
|
||||
...rest,
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ 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,7 +27,6 @@ export function ContactsHeroImage() {
|
||||
alt={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -667,6 +667,7 @@ function SearchInput({
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={l`Search`}
|
||||
value={value}
|
||||
|
||||
@@ -113,7 +113,6 @@ 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,7 +124,6 @@ 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,7 +101,6 @@ 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,7 +78,6 @@ export function FindContactsAnnouncement() {
|
||||
alt={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -85,7 +85,6 @@ export function InitialVerificationAnnouncement() {
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -120,7 +119,6 @@ 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,7 +150,6 @@ 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,7 +11,6 @@ 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'
|
||||
@@ -142,15 +141,6 @@ 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: [],
|
||||
@@ -481,7 +471,6 @@ export function AddMembersFlow({
|
||||
values={groupChatDids}
|
||||
onChange={setGroupChatMembers}
|
||||
type="checkbox"
|
||||
maxSelections={remainingSlots}
|
||||
label={l`Add group chat members`}
|
||||
style={web([a.contents])}>
|
||||
<Dialog.InnerFlatList
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export {Card} from './Card'
|
||||
export {
|
||||
type ChatInviteAction,
|
||||
type ChatInviteContextValue,
|
||||
useChatInvite,
|
||||
} from './Context'
|
||||
export {JoinButton} from './JoinButton'
|
||||
export {Root} from './Root'
|
||||
@@ -10,10 +10,8 @@ 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'
|
||||
@@ -214,7 +212,6 @@ export function InitiateChatFlow({
|
||||
|
||||
const {data: chatStatus} = useChatActorStatusQuery()
|
||||
const canCreateGroups = chatStatus?.canCreateGroups ?? true
|
||||
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
@@ -461,11 +458,6 @@ 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
|
||||
@@ -478,7 +470,7 @@ export function InitiateChatFlow({
|
||||
buttonText = l`Create`
|
||||
handleButtonPress = handlePressConfirm
|
||||
showButton = true
|
||||
isButtonDisabled = groupName === '' || groupNameTooLong
|
||||
isButtonDisabled = groupName === ''
|
||||
break
|
||||
}
|
||||
|
||||
@@ -572,7 +564,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 isInvalid={groupNameTooLong}>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={l`Group name`}
|
||||
value={groupName}
|
||||
@@ -581,7 +573,6 @@ export function InitiateChatFlow({
|
||||
selectTextOnFocus={IS_NATIVE}
|
||||
autoFocus={false}
|
||||
accessibilityRole="text"
|
||||
clearButtonMode="while-editing"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
@@ -591,20 +582,6 @@ 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
|
||||
@@ -645,8 +622,6 @@ export function InitiateChatFlow({
|
||||
handleButtonPress,
|
||||
buttonText,
|
||||
groupName,
|
||||
groupNameTooLong,
|
||||
t.palette.negative_400,
|
||||
searchText,
|
||||
control,
|
||||
showChatProfileTabs,
|
||||
@@ -689,11 +664,6 @@ 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,9 +1,7 @@
|
||||
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'
|
||||
@@ -32,18 +30,10 @@ export function LeaveConvoPrompt({
|
||||
)
|
||||
}
|
||||
},
|
||||
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'})
|
||||
onError: () => {
|
||||
Toast.show(l`Could not leave chat`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -53,7 +43,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 participants.`
|
||||
? 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?`
|
||||
}
|
||||
confirmButtonCta={l`Leave`}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
@@ -50,7 +49,6 @@ 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'
|
||||
|
||||
@@ -187,10 +185,8 @@ let MessageItem = ({
|
||||
|
||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||
|
||||
const hasEmbed =
|
||||
AppBskyEmbedRecord.isView(message.embed) ||
|
||||
ChatBskyEmbedJoinLink.isView(message.embed)
|
||||
const hasEmbedAndText = hasEmbed && rt.text.length > 0
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
|
||||
const targetBottomRadius = squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
@@ -431,15 +427,6 @@ 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`}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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,22 +125,6 @@ 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',
|
||||
@@ -169,11 +153,11 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||
onClose={() => setAfterReportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
{reactionsMessage && (
|
||||
{reactionsTarget && (
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
relatedProfiles={convo.relatedProfiles}
|
||||
message={reactionsMessage}
|
||||
message={reactionsTarget}
|
||||
onClose={() => setReactionsTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -34,40 +34,32 @@ export function GroupChatProfileCard({
|
||||
name={profile.did}
|
||||
label={displayName}
|
||||
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||
{({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 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>
|
||||
)}
|
||||
</View>
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
</>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,22 +47,6 @@ 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,7 +142,6 @@ export function AutoSizedImage({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
|
||||
@@ -343,11 +343,6 @@ 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,
|
||||
@@ -488,38 +483,8 @@ 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,5 +1,4 @@
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyFeedDefs,
|
||||
@@ -28,6 +27,9 @@ 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,
|
||||
@@ -37,13 +39,6 @@ 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
|
||||
*/
|
||||
@@ -54,12 +49,6 @@ export function maybeApplyGalleryOffsetStyles(
|
||||
embed,
|
||||
AppBskyEmbedImages.isMain,
|
||||
)
|
||||
const isGalleryEmbed =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
|
||||
embed,
|
||||
AppBskyEmbedGallery.isMain,
|
||||
)
|
||||
const isRecordWithMedia =
|
||||
embed &&
|
||||
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
|
||||
@@ -68,16 +57,10 @@ 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>(
|
||||
@@ -85,19 +68,9 @@ 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,7 +4,6 @@ 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
|
||||
@@ -247,64 +246,12 @@ 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,28 +19,21 @@ interface ImageLayoutGridProps {
|
||||
onPressIn?: (index: number) => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
}
|
||||
|
||||
export function ImageLayoutGrid({
|
||||
style,
|
||||
isWithinQuote: isWithinQuoteProp,
|
||||
...props
|
||||
}: ImageLayoutGridProps) {
|
||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isWithinQuote =
|
||||
isWithinQuoteProp ??
|
||||
const gap =
|
||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||
? 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}
|
||||
isWithinQuote={isWithinQuote}
|
||||
/>
|
||||
<ImageLayoutGridInner {...props} gap={gap} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -56,7 +49,6 @@ interface ImageLayoutGridInnerProps {
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
gap: {gap: number}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ interface Props {
|
||||
onPressIn?: EventFunction
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
insetBorderStyle?: StyleProp<ViewStyle>
|
||||
containerRefs: AnimatedRef<any>[]
|
||||
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
||||
@@ -43,7 +42,6 @@ export function GalleryItem({
|
||||
onPressIn,
|
||||
onLongPress,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
@@ -54,7 +52,6 @@ export function GalleryItem({
|
||||
const image = images[index]
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
isWithinQuote ??
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
|
||||
const aspect =
|
||||
@@ -109,7 +106,6 @@ export function GalleryItem({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
|
||||
@@ -81,7 +81,6 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
||||
codes: code ? [code] : undefined,
|
||||
hasSession,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
||||
@@ -134,7 +133,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 previously removed from this group and can’t join it using this link.`
|
||||
errorMessage = l`You have been removed from this group.`
|
||||
}
|
||||
Toast.show(errorMessage)
|
||||
},
|
||||
@@ -327,7 +326,6 @@ 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,
|
||||
@@ -404,9 +402,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
color="primary"
|
||||
disabled={!code}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Open chat</Trans>
|
||||
</ButtonText>
|
||||
<ButtonText>Open chat</ButtonText>
|
||||
<ButtonIcon icon={ArrowRightIcon} />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -420,8 +416,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
}
|
||||
accessibilityHint={
|
||||
joinLinkPreview.requireApproval
|
||||
? l`Tap to request access to join this group chat`
|
||||
: l`Tap to join this group chat immediately`
|
||||
? l`Request access to join this group chat`
|
||||
: l`Join this group chat`
|
||||
}
|
||||
size="large"
|
||||
color={buttonColor}
|
||||
|
||||
@@ -87,10 +87,7 @@ export function parseReportSubject(
|
||||
reply: !!record.reply,
|
||||
image:
|
||||
embed.type === 'images' ||
|
||||
embed.type === 'gallery' ||
|
||||
(embed.type === 'post_with_media' &&
|
||||
(embed.media.type === 'images' ||
|
||||
embed.media.type === 'gallery')),
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
|
||||
video:
|
||||
embed.type === 'video' ||
|
||||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
||||
|
||||
@@ -87,7 +87,6 @@ function Inner({
|
||||
alt={_(
|
||||
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
|
||||
)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ export function LiveEventFeedCardCompact({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -77,7 +77,6 @@ export function LiveEventFeedCardWide({
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
|
||||
@@ -54,7 +54,6 @@ export function LinkPreview({
|
||||
contentFit="cover"
|
||||
onLoad={() => setImageLoadError(false)}
|
||||
onError={() => setImageLoadError(true)}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
)}
|
||||
{linkMeta && (!linkMeta.image || imageLoadError) && (
|
||||
|
||||
@@ -147,7 +147,6 @@ export function LiveStatus({
|
||||
contentFit="cover"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<LiveIndicator
|
||||
size="large"
|
||||
|
||||
+3
-52
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedExternal,
|
||||
type AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyEmbedRecord,
|
||||
type AppBskyEmbedRecordWithMedia,
|
||||
@@ -22,7 +21,6 @@ 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'
|
||||
@@ -179,8 +177,7 @@ export async function post(
|
||||
writes: writes,
|
||||
validate: true,
|
||||
})
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to create post`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
@@ -255,7 +252,6 @@ async function resolveEmbed(
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
@@ -315,7 +311,6 @@ async function resolveMedia(
|
||||
): Promise<
|
||||
| $Typed<AppBskyEmbedExternal.Main>
|
||||
| $Typed<AppBskyEmbedImages.Main>
|
||||
| $Typed<AppBskyEmbedGallery.Main>
|
||||
| $Typed<AppBskyEmbedVideo.Main>
|
||||
| undefined
|
||||
> {
|
||||
@@ -328,10 +323,7 @@ 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,
|
||||
IMAGE_SIZE_CONFIG_POSTS,
|
||||
)
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
logger.debug(`Uploading image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
@@ -346,34 +338,6 @@ 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'
|
||||
@@ -463,16 +427,6 @@ 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
|
||||
}
|
||||
@@ -516,7 +470,6 @@ 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,
|
||||
@@ -539,10 +492,9 @@ function prepareForHashing(v: any): any {
|
||||
|
||||
// Walk through plain objects
|
||||
if (isPlainObject(v)) {
|
||||
const obj: Record<string, unknown> = {}
|
||||
const obj: any = {}
|
||||
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) {
|
||||
@@ -561,7 +513,6 @@ 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
|
||||
|
||||
+5
-25
@@ -2,12 +2,11 @@ import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
type BskyAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
|
||||
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
|
||||
import {downloadAndResize} from '#/lib/media/manip'
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyCustomFeedUrl,
|
||||
isBskyListUrl,
|
||||
isBskyPostUrl,
|
||||
@@ -73,20 +71,12 @@ type ResolvedStarterPackRecord = {
|
||||
view: AppBskyGraphDefs.StarterPackView
|
||||
}
|
||||
|
||||
type ResolvedChatInvite = {
|
||||
type: 'chat-invite'
|
||||
uri: string
|
||||
code: string
|
||||
view?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
}
|
||||
|
||||
export type ResolvedLink =
|
||||
| ResolvedExternalLink
|
||||
| ResolvedPostRecord
|
||||
| ResolvedFeedRecord
|
||||
| ResolvedListRecord
|
||||
| ResolvedStarterPackRecord
|
||||
| ResolvedChatInvite
|
||||
|
||||
export class EmbeddingDisabledError extends Error {
|
||||
constructor() {
|
||||
@@ -151,19 +141,6 @@ export async function resolveLink(
|
||||
view: res.data.list,
|
||||
}
|
||||
}
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(uri)
|
||||
if (chatInviteCode) {
|
||||
const res = await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes: [chatInviteCode]},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
return {
|
||||
type: 'chat-invite',
|
||||
uri,
|
||||
code: chatInviteCode,
|
||||
view: res.data.joinLinkPreviews[0],
|
||||
}
|
||||
}
|
||||
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
|
||||
const parsed = parseStarterPackUri(uri)
|
||||
if (!parsed) {
|
||||
@@ -284,7 +261,10 @@ export async function imageToThumb(
|
||||
try {
|
||||
const img = await downloadAndResize({
|
||||
uri: imageUri,
|
||||
...IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
timeout: 15e3,
|
||||
})
|
||||
if (img) {
|
||||
|
||||
+4
-10
@@ -67,8 +67,6 @@ export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
|
||||
|
||||
export const MAX_DM_GRAPHEME_LENGTH = 1000
|
||||
|
||||
export const MAX_GROUP_NAME_GRAPHEME_LENGTH = 50
|
||||
|
||||
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 2000
|
||||
@@ -99,14 +97,10 @@ export const STAGING_FEEDS = [
|
||||
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
|
||||
]
|
||||
|
||||
export const IMAGE_SIZE_CONFIG_POSTS = {
|
||||
maxDimension: 4000,
|
||||
maxSize: 2000000,
|
||||
}
|
||||
|
||||
export const IMAGE_SIZE_CONFIG_2K_1MB = {
|
||||
maxDimension: 2000,
|
||||
maxSize: 1000000,
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
size: 1000000,
|
||||
}
|
||||
|
||||
export const STAGING_LINK_META_PROXY =
|
||||
|
||||
+39
-16
@@ -16,21 +16,24 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type Dimensions} from './types'
|
||||
import {convertCdnPreset, getResizedDimensions} from './util'
|
||||
import {convertCdnPreset} from './util'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: PickerImage,
|
||||
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
||||
maxSize: number = POST_IMG_MAX.size,
|
||||
): Promise<PickerImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
const resizedImage = await doResize(normalizePath(img.path), {
|
||||
maxDimension,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
const finalImageMovedPath = await moveToPermanentPath(
|
||||
@@ -46,7 +49,9 @@ export async function compressIfNeeded(
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
uri: string
|
||||
maxDimension: number
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
timeout: number
|
||||
}
|
||||
@@ -62,10 +67,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)
|
||||
|
||||
try {
|
||||
return await doResize(path, {
|
||||
maxDimension: opts.maxDimension,
|
||||
maxSize: opts.maxSize,
|
||||
})
|
||||
return await doResize(path, opts)
|
||||
} finally {
|
||||
void safeDeleteAsync(path)
|
||||
}
|
||||
@@ -186,7 +188,9 @@ export function getImageDim(path: string): Promise<Dimensions> {
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
maxDimension: number
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
@@ -200,13 +204,10 @@ async function doResize(
|
||||
// Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
|
||||
// does not work for local files...
|
||||
const imageRes = await manipulateAsync(localUri, [], {})
|
||||
const newDimensions = getResizedDimensions(
|
||||
{
|
||||
width: imageRes.width,
|
||||
height: imageRes.height,
|
||||
},
|
||||
opts.maxDimension,
|
||||
)
|
||||
const newDimensions = getResizedDimensions({
|
||||
width: imageRes.width,
|
||||
height: imageRes.height,
|
||||
})
|
||||
|
||||
let minQualityPercentage = 0
|
||||
let maxQualityPercentage = 101 // exclusive
|
||||
@@ -387,6 +388,28 @@ async function withTempFile<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export function getResizedDimensions(originalDims: {
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
if (
|
||||
originalDims.width <= POST_IMG_MAX.width &&
|
||||
originalDims.height <= POST_IMG_MAX.height
|
||||
) {
|
||||
return originalDims
|
||||
}
|
||||
|
||||
const ratio = Math.min(
|
||||
POST_IMG_MAX.width / originalDims.width,
|
||||
POST_IMG_MAX.height / originalDims.height,
|
||||
)
|
||||
|
||||
return {
|
||||
width: Math.round(originalDims.width * ratio),
|
||||
height: Math.round(originalDims.height * ratio),
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImage(uri: string, destName: string, timeout: number) {
|
||||
// Download to a temp path first, then rename with the correct extension
|
||||
// based on the response's mimeType.
|
||||
|
||||
+17
-22
@@ -1,28 +1,27 @@
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type Dimensions} from './types'
|
||||
import {
|
||||
blobToDataUri,
|
||||
convertCdnPreset,
|
||||
getDataUriSize,
|
||||
getResizedDimensions,
|
||||
} from './util'
|
||||
import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: PickerImage,
|
||||
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
||||
maxSize: number,
|
||||
): Promise<PickerImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
return await doResize(img.path, {
|
||||
maxDimension,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
}
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
uri: string
|
||||
maxDimension: number
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
timeout: number
|
||||
}
|
||||
@@ -35,10 +34,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
clearTimeout(to)
|
||||
|
||||
const dataUri = await blobToDataUri(resBody)
|
||||
return await doResize(dataUri, {
|
||||
maxDimension: opts.maxDimension,
|
||||
maxSize: opts.maxSize,
|
||||
})
|
||||
return await doResize(dataUri, opts)
|
||||
}
|
||||
|
||||
export async function shareImageModal(_opts: {uri: string}) {
|
||||
@@ -74,7 +70,9 @@ export async function getImageDim(path: string): Promise<Dimensions> {
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
maxDimension: number
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
@@ -82,9 +80,6 @@ async function doResize(
|
||||
dataUri: string,
|
||||
opts: DoResizeOpts,
|
||||
): Promise<PickerImage> {
|
||||
const sourceDims = await getImageDim(dataUri)
|
||||
const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension)
|
||||
|
||||
let newDataUri
|
||||
|
||||
let minQualityPercentage = 0
|
||||
@@ -95,10 +90,10 @@ async function doResize(
|
||||
(maxQualityPercentage + minQualityPercentage) / 2,
|
||||
)
|
||||
const tempDataUri = await createResizedImage(dataUri, {
|
||||
width: newDimensions.width,
|
||||
height: newDimensions.height,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
quality: qualityPercentage / 100,
|
||||
mode: 'contain',
|
||||
mode: opts.mode,
|
||||
})
|
||||
|
||||
if (getDataUriSize(tempDataUri) < opts.maxSize) {
|
||||
@@ -116,8 +111,8 @@ async function doResize(
|
||||
path: newDataUri,
|
||||
mime: 'image/jpeg',
|
||||
size: getDataUriSize(newDataUri),
|
||||
width: newDimensions.width,
|
||||
height: newDimensions.height,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import ExpoImageCropTool, {
|
||||
type OpenCropperOptions,
|
||||
} from '@bsky.app/expo-image-crop-tool'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {compressIfNeeded} from './manip'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
|
||||
@@ -29,16 +28,13 @@ async function getFile() {
|
||||
throw new Error('Failed to get file info')
|
||||
}
|
||||
|
||||
return await compressIfNeeded(
|
||||
{
|
||||
path: file,
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
},
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
)
|
||||
return await compressIfNeeded({
|
||||
path: file,
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openPicker(): Promise<PickerImage[]> {
|
||||
|
||||
@@ -2,31 +2,6 @@ export function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
||||
|
||||
export function getResizedDimensions(
|
||||
originalDims: {
|
||||
width: number
|
||||
height: number
|
||||
},
|
||||
maxDimension: number,
|
||||
) {
|
||||
if (
|
||||
originalDims.width <= maxDimension &&
|
||||
originalDims.height <= maxDimension
|
||||
) {
|
||||
return originalDims
|
||||
}
|
||||
|
||||
const ratio = Math.min(
|
||||
maxDimension / originalDims.width,
|
||||
maxDimension / originalDims.height,
|
||||
)
|
||||
|
||||
return {
|
||||
width: Math.round(originalDims.width * ratio),
|
||||
height: Math.round(originalDims.height * ratio),
|
||||
}
|
||||
}
|
||||
|
||||
// Fairly accurate estimate that is more performant
|
||||
// than decoding and checking length of URI
|
||||
export function getDataUriSize(uri: string): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {parse} from 'psl'
|
||||
import psl from 'psl'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
import {BSKY_SERVICE} from '#/lib/constants'
|
||||
@@ -178,7 +178,6 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
|
||||
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
||||
|
||||
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
||||
@@ -329,7 +328,7 @@ export function isPossiblyAUrl(str: string): boolean {
|
||||
}
|
||||
|
||||
export function splitApexDomain(hostname: string): [string, string] {
|
||||
const hostnamep = parse(hostname)
|
||||
const hostnamep = psl.parse(hostname)
|
||||
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
||||
return ['', hostname]
|
||||
}
|
||||
|
||||
+669
-786
File diff suppressed because one or more lines are too long
@@ -451,12 +451,6 @@ export function Header({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
const leftConvos = useLeftConvos()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
// In split view, the left column (and this header) stays mounted while the
|
||||
// right column shows the selected route. Pushing would stack duplicate routes
|
||||
// on repeated clicks, so navigate instead to dedupe by route + params.
|
||||
const action = isWithinSplitView ? 'navigate' : 'push'
|
||||
|
||||
const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
|
||||
useListConvosQuery({
|
||||
@@ -500,11 +494,9 @@ export function Header({
|
||||
count={inboxAllConvos.length}
|
||||
more={hasMoreRequests}
|
||||
variant="solid"
|
||||
action={action}
|
||||
/>
|
||||
<Link
|
||||
to="/messages/settings"
|
||||
action={action}
|
||||
label={l`Chat settings`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {type LayoutChangeEvent, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
ScrollEdgeEffect,
|
||||
ScrollEdgeEffectProvider,
|
||||
@@ -29,7 +29,6 @@ import {ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
||||
import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read'
|
||||
import {useSession} from '#/state/session'
|
||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
@@ -52,7 +51,6 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env'
|
||||
import {ChatDisabled} from './components/ChatDisabled'
|
||||
import {ChatEnded} from './components/ChatEnded'
|
||||
import {ChatLocked} from './components/ChatLocked'
|
||||
import {RequestStatus} from './components/RequestStatus'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -182,12 +180,6 @@ function InnerReady({
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const emailDialogControl = useEmailDialogControl()
|
||||
|
||||
const unreadRequestCount =
|
||||
convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind)
|
||||
? (convo.view.kind.unreadJoinRequestCount ?? 0)
|
||||
: 0
|
||||
const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id)
|
||||
|
||||
/**
|
||||
* Must be non-reactive, otherwise the update to open the global dialog will
|
||||
* cause a re-render loop.
|
||||
@@ -272,25 +264,8 @@ function InnerReady({
|
||||
{header}
|
||||
</ScrollEdgeEffect>
|
||||
) : (
|
||||
<View onLayout={onHeaderLayout}>{header}</View>
|
||||
header
|
||||
)}
|
||||
|
||||
{isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? (
|
||||
<RequestStatus
|
||||
top={headerHeight}
|
||||
count={unreadRequestCount}
|
||||
onDismiss={() => {
|
||||
markJoinRequestsRead()
|
||||
}}
|
||||
onPress={() => {
|
||||
markJoinRequestsRead()
|
||||
navigation.navigate('MessagesJoinRequests', {
|
||||
conversation: convo.view.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isActive && (
|
||||
<MessagesList
|
||||
hasScrolled={hasScrolled}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function EditNamePrompt({
|
||||
control,
|
||||
@@ -20,14 +17,8 @@ export function EditNamePrompt({
|
||||
onChangeText: (value: string) => void
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const nameTooLong = isOverMaxGraphemeCount({
|
||||
text: value,
|
||||
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
|
||||
})
|
||||
|
||||
return (
|
||||
<Prompt.Outer control={control}>
|
||||
<>
|
||||
@@ -36,7 +27,7 @@ export function EditNamePrompt({
|
||||
<Trans>Edit group name</Trans>
|
||||
</Prompt.TitleText>
|
||||
<View style={[a.my_sm]}>
|
||||
<TextField.Root isInvalid={nameTooLong}>
|
||||
<TextField.Root isInvalid={false}>
|
||||
<TextField.Input
|
||||
label={l`Edit group name`}
|
||||
placeholder={l`Group name`}
|
||||
@@ -47,31 +38,13 @@ export function EditNamePrompt({
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={nameTooLong ? undefined : onConfirm}
|
||||
onSubmitEditing={onConfirm}
|
||||
/>
|
||||
</TextField.Root>
|
||||
{nameTooLong ? (
|
||||
<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>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
cta={l`Save`}
|
||||
onPress={onConfirm}
|
||||
disabled={nameTooLong}
|
||||
/>
|
||||
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</>
|
||||
|
||||
@@ -26,9 +26,10 @@ import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -61,6 +62,8 @@ export function MessagesInboxScreen(props: Props) {
|
||||
}
|
||||
|
||||
export function MessagesInboxScreenInner({}: Props) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
|
||||
const listConvosQuery = useListConvosQuery({status: 'request'})
|
||||
const {data} = listConvosQuery
|
||||
|
||||
@@ -91,16 +94,21 @@ export function MessagesInboxScreenInner({}: Props) {
|
||||
<Layout.Screen testID="messagesInboxScreen">
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content align="left">
|
||||
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Chat requests</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
{hasUnreadConvos ? <MarkAsReadHeaderButton /> : <Layout.Header.Slot />}
|
||||
{hasUnreadConvos && gtTablet ? (
|
||||
<MarkAsReadHeaderButton />
|
||||
) : (
|
||||
<Layout.Header.Slot />
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
<RequestList
|
||||
listConvosQuery={listConvosQuery}
|
||||
conversations={conversations}
|
||||
hasUnreadConvos={hasUnreadConvos}
|
||||
/>
|
||||
</Layout.Screen>
|
||||
)
|
||||
@@ -109,12 +117,14 @@ export function MessagesInboxScreenInner({}: Props) {
|
||||
function RequestList({
|
||||
listConvosQuery,
|
||||
conversations,
|
||||
hasUnreadConvos,
|
||||
}: {
|
||||
listConvosQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
|
||||
Error
|
||||
>
|
||||
conversations: ChatBskyConvoDefs.ConvoView[]
|
||||
hasUnreadConvos: boolean
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -275,6 +285,7 @@ function RequestList({
|
||||
desktopFixedHeight
|
||||
sideBorders={false}
|
||||
/>
|
||||
{hasUnreadConvos && <MarkAllReadFAB />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -287,6 +298,34 @@ function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
|
||||
return <RequestListItem convo={item} />
|
||||
}
|
||||
|
||||
function MarkAllReadFAB() {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {mutate: markAllRead} = useUpdateAllRead('request', {
|
||||
onMutate: () => {
|
||||
Toast.show(l`Marked all as read`, {
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to mark all requests as read`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<FAB
|
||||
testID="markAllAsReadFAB"
|
||||
onPress={() => markAllRead()}
|
||||
icon={<CheckIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Mark all as read`}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkAsReadHeaderButton() {
|
||||
const {t: l} = useLingui()
|
||||
const {mutate: markAllRead} = useUpdateAllRead('request', {
|
||||
|
||||
@@ -411,6 +411,7 @@ function Header({
|
||||
count?: number
|
||||
hasMoreRequests?: boolean
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
@@ -419,15 +420,15 @@ function Header({
|
||||
{count === undefined ? (
|
||||
<Trans>Requests to join</Trans>
|
||||
) : hasMoreRequests ? (
|
||||
<Plural
|
||||
value={count}
|
||||
other="#+ requests to join"
|
||||
comment="Displayed when there are more requests to join a group chat than have been loaded"
|
||||
/>
|
||||
l({
|
||||
message: `${count}+ requests to join`,
|
||||
comment:
|
||||
'Displayed when there are more requests to join a group chat than have been loaded',
|
||||
})
|
||||
) : (
|
||||
<Plural
|
||||
value={count}
|
||||
_0="No requests to join"
|
||||
zero="No requests to join"
|
||||
one="# request to join"
|
||||
other="# requests to join"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {resolveAllowGroupInvites} from '#/components/dms/util'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
|
||||
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
|
||||
@@ -200,7 +199,10 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
<Toggle.Group
|
||||
label={l`Allow group chat invites from`}
|
||||
type="radio"
|
||||
values={[resolveAllowGroupInvites(profile?.associated?.chat)]}
|
||||
values={[
|
||||
(profile?.associated?.chat
|
||||
?.allowGroupInvites as AllowIncoming) ?? 'following',
|
||||
]}
|
||||
onChange={onSelectGroupInvitesFrom}>
|
||||
<View>
|
||||
{allowGroupInvitesFromOptions.map(option => (
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
precacheConvoQuery,
|
||||
useMarkAsReadMutation,
|
||||
} from '#/state/queries/messages/conversation'
|
||||
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
@@ -122,7 +121,7 @@ function DirectChatItem({
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const profile = useProfileShadow(convo.primaryMember)
|
||||
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
@@ -140,7 +139,7 @@ function DirectChatItem({
|
||||
avatar={
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
size={isWithinLeftPanel ? 48 : 52}
|
||||
size={isWithinSplitView ? 48 : 52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
}
|
||||
@@ -161,7 +160,7 @@ function DirectChatItem({
|
||||
isBlockedAccount={moderation.blocked}
|
||||
showProfileBadges
|
||||
postAlerts={
|
||||
isWithinLeftPanel ? null : (
|
||||
isWithinSplitView ? null : (
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="sm"
|
||||
@@ -189,7 +188,7 @@ function GroupChatItem({
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
|
||||
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
@@ -205,7 +204,7 @@ function GroupChatItem({
|
||||
avatar={
|
||||
<AvatarBubbles
|
||||
profiles={convo.members}
|
||||
size={isWithinLeftPanel ? 48 : 52}
|
||||
size={isWithinSplitView ? 48 : 52}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
}
|
||||
@@ -215,15 +214,15 @@ function GroupChatItem({
|
||||
primaryProfileModeration={moderation}
|
||||
isBlockedAccount={false}
|
||||
isDeletedAccount={false}
|
||||
requestInfo={
|
||||
convo.details.unreadJoinRequestCount
|
||||
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
|
||||
subtitle={
|
||||
convo.details.joinRequestCount
|
||||
? convo.details.joinRequestCount > 20
|
||||
? l({
|
||||
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||
message: '20+ new join requests',
|
||||
context:
|
||||
'Displayed when there are more than 20 requests to join a group chat',
|
||||
})
|
||||
: plural(convo.details.unreadJoinRequestCount, {
|
||||
: plural(convo.details.joinRequestCount, {
|
||||
one: '# new join request',
|
||||
other: '# new join requests',
|
||||
})
|
||||
@@ -242,7 +241,6 @@ function BaseChatItem({
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
requestInfo,
|
||||
accessibilityHint,
|
||||
isDeletedAccount,
|
||||
isBlockedAccount,
|
||||
@@ -258,7 +256,6 @@ function BaseChatItem({
|
||||
avatar: React.ReactNode
|
||||
title: string
|
||||
subtitle?: string
|
||||
requestInfo?: string
|
||||
accessibilityHint: string
|
||||
isDeletedAccount: boolean
|
||||
isBlockedAccount: boolean
|
||||
@@ -278,15 +275,13 @@ function BaseChatItem({
|
||||
const leaveConvoControl = useDialogControl()
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
const playHaptic = useHaptics()
|
||||
const queryClient = useQueryClient()
|
||||
const hasUnread =
|
||||
convo.view.unreadCount > 0 &&
|
||||
!isDeletedAccount &&
|
||||
(convo.view.unreadCount > 0 ||
|
||||
(convo.kind === 'group' &&
|
||||
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
|
||||
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
||||
|
||||
const blockInfo = useMemo(() => {
|
||||
@@ -458,7 +453,7 @@ function BaseChatItem({
|
||||
leftFirst: deleteAction,
|
||||
}
|
||||
|
||||
const avatarSize = isWithinLeftPanel ? 48 : 52
|
||||
const avatarSize = isWithinSplitView ? 48 : 52
|
||||
|
||||
return (
|
||||
<ChatListItemPortal.Provider>
|
||||
@@ -469,22 +464,18 @@ function BaseChatItem({
|
||||
// @ts-expect-error web only
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
|
||||
style={[a.relative, t.atoms.bg, isWithinSplitView && a.mx_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
{top: tokens.space.md, left: tokens.space.lg},
|
||||
isGroupConvo && a.pointer_events_none,
|
||||
]}>
|
||||
{avatar}
|
||||
</View>
|
||||
|
||||
<Link
|
||||
to={`/messages/${convo.view.id}`}
|
||||
// In split view, this list stays mounted alongside the open convo,
|
||||
// so push would stack duplicate routes on repeated clicks.
|
||||
action={isWithinLeftPanel ? 'navigate' : 'push'}
|
||||
label={title}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityActions={
|
||||
@@ -516,7 +507,7 @@ function BaseChatItem({
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
isWithinLeftPanel && a.rounded_sm,
|
||||
isWithinSplitView && a.rounded_sm,
|
||||
{
|
||||
backgroundColor: hasUnread
|
||||
? t.palette.primary_25
|
||||
@@ -616,19 +607,6 @@ function BaseChatItem({
|
||||
|
||||
{postAlerts}
|
||||
|
||||
{requestInfo && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
a.pb_2xs,
|
||||
]}
|
||||
emoji>
|
||||
{requestInfo}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
{LastMessageIcon && (
|
||||
<LastMessageIcon
|
||||
@@ -645,6 +623,8 @@ function BaseChatItem({
|
||||
emoji
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
|
||||
@@ -11,12 +11,10 @@ export function InboxRequests({
|
||||
count,
|
||||
more,
|
||||
variant,
|
||||
action,
|
||||
}: {
|
||||
count: number
|
||||
more: boolean
|
||||
variant?: 'ghost' | 'solid'
|
||||
action?: 'push' | 'navigate'
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
@@ -43,7 +41,6 @@ export function InboxRequests({
|
||||
<Link
|
||||
label={label}
|
||||
to="/messages/inbox"
|
||||
action={action}
|
||||
size="small"
|
||||
variant={unread ? 'solid' : 'ghost'}
|
||||
color={unread ? 'primary_subtle' : 'secondary'}
|
||||
@@ -69,7 +66,6 @@ export function InboxRequests({
|
||||
<Link
|
||||
label={label}
|
||||
to="/messages/inbox"
|
||||
action={action}
|
||||
color={unread ? 'primary_subtle' : 'secondary'}
|
||||
size="small">
|
||||
<ButtonIcon icon={InboxIcon} />
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -177,7 +178,11 @@ export function InviteLinkDialog({
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Group chats can only have a maximum of{' '}
|
||||
<Plural value={convo.details.memberLimit} other="# people" />.
|
||||
{plural(convo.details.memberLimit, {
|
||||
one: '# person',
|
||||
other: '# people',
|
||||
})}
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {useEmail} from '#/state/email-verification'
|
||||
import {
|
||||
useMessageDraft,
|
||||
@@ -233,11 +233,7 @@ export function MessageComposer({
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onFacetCommitted={facet => {
|
||||
if (
|
||||
facet.type === 'url' &&
|
||||
(isBskyPostUrl(facet.value) ||
|
||||
isBskyChatInviteUrl(facet.value))
|
||||
) {
|
||||
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||
setEmbed(facet.value)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyChatInviteUrl,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -28,7 +26,6 @@ import {usePostQuery} from '#/state/queries/post'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
@@ -38,56 +35,35 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* The embed staged in the message composer. A message can carry at most one
|
||||
* embed: either a quoted post or a group chat invite link.
|
||||
*/
|
||||
export type MessageEmbedState =
|
||||
| {type: 'post'; uri: string}
|
||||
| {type: 'invite'; code: string}
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
|
||||
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
|
||||
)
|
||||
const [embedUri, setEmbedUri] = useState(embedFromParams)
|
||||
|
||||
if (embedFromParams && embed?.type !== 'post') {
|
||||
setEmbedState({type: 'post', uri: embedFromParams})
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbedUri(embedFromParams)
|
||||
}
|
||||
|
||||
return {
|
||||
embed,
|
||||
embedUri,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
// Only the post embed is reflected in the route param (used by the
|
||||
// share-to-DM intent flow); invites are local-only.
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbedState(undefined)
|
||||
setEmbedUri(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
if (isBskyChatInviteUrl(embedUrl)) {
|
||||
const code = getChatInviteCodeFromUrl(embedUrl)
|
||||
if (code) {
|
||||
setEmbedState({type: 'invite', code})
|
||||
}
|
||||
return
|
||||
}
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
if (isBskyPostUrl(embedUrl)) {
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
setEmbedState({type: 'post', uri})
|
||||
}
|
||||
setEmbedUri(uri)
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
@@ -105,10 +81,7 @@ export function useExtractEmbedFromFacets(
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (
|
||||
AppBskyRichtextFacet.isLink(feature) &&
|
||||
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
|
||||
) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
@@ -123,40 +96,16 @@ export function useExtractEmbedFromFacets(
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embed,
|
||||
embedUri,
|
||||
setEmbed,
|
||||
}: {
|
||||
embed: MessageEmbedState | undefined
|
||||
embedUri: string | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const onRemove = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}, [setEmbed])
|
||||
|
||||
if (!embed) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case 'post':
|
||||
return <MessageInputPostEmbed uri={embed.uri} onRemove={onRemove} />
|
||||
case 'invite':
|
||||
return <MessageInputInviteEmbed code={embed.code} onRemove={onRemove} />
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputPostEmbed({
|
||||
uri,
|
||||
onRemove,
|
||||
}: {
|
||||
uri: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(uri)
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
@@ -185,6 +134,15 @@ function MessageInputPostEmbed({
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending': {
|
||||
return (
|
||||
@@ -262,71 +220,6 @@ function MessageInputPostEmbed({
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbed({
|
||||
code,
|
||||
onRemove,
|
||||
}: {
|
||||
code: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<ChatInvite.Root code={code} hasFixedHeight={false}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mt_sm,
|
||||
a.mx_sm,
|
||||
]}>
|
||||
<MessageInputInviteEmbedBody />
|
||||
<Button
|
||||
label={l`Remove embed`}
|
||||
onPress={onRemove}
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: 10, right: 8},
|
||||
a.px_2xs,
|
||||
{transform: [{translateY: -2}]},
|
||||
]}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<XIcon size="xs" style={t.atoms.text_contrast_high} />
|
||||
</Button>
|
||||
</View>
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbedBody() {
|
||||
const t = useTheme()
|
||||
const {loading, preview} = ChatInvite.useChatInvite()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
|
||||
<Trans>Could not load invite</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChatInvite.Card size="small" />
|
||||
}
|
||||
|
||||
function SimpleContainer({
|
||||
children,
|
||||
onRemove,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||
@@ -38,7 +37,6 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
@@ -48,10 +46,9 @@ import {
|
||||
useConvoActive,
|
||||
} from '#/state/messages/convo'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
@@ -134,10 +131,8 @@ export function MessagesList({
|
||||
const ax = useAnalytics()
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const getPost = useGetPost()
|
||||
const getJoinLinkPreview = useGetJoinLinkPreview()
|
||||
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
const t = useTheme()
|
||||
|
||||
const textInputId = 'chat-input-' + useId()
|
||||
@@ -353,38 +348,12 @@ export function MessagesList({
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed:
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
| $Typed<ChatBskyEmbedJoinLink.Main>
|
||||
| undefined
|
||||
let embedView:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined
|
||||
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
|
||||
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined
|
||||
|
||||
// Find the embedded link facet and, if it's at the start or end of the
|
||||
// message, remove it from the text (the embed card replaces it).
|
||||
const stripLinkFacet = (predicate: (uri: string) => boolean) => {
|
||||
const linkFacet = rt.facets?.find(facet =>
|
||||
facet.features.find(
|
||||
feature =>
|
||||
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
|
||||
),
|
||||
)
|
||||
if (linkFacet) {
|
||||
const isAtStart = linkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
linkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd)
|
||||
}
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
|
||||
if (messageEmbed?.type === 'post') {
|
||||
if (embedUri) {
|
||||
try {
|
||||
const post = await getPost({uri: messageEmbed.uri})
|
||||
const post = await getPost({uri: embedUri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
@@ -399,34 +368,42 @@ export function MessagesList({
|
||||
record: createEmbedViewRecordFromPost(post),
|
||||
}
|
||||
|
||||
stripLinkFacet(uri => {
|
||||
if (!isBskyPostUrl(uri)) return false
|
||||
const url = convertBskyAppUrlIfNeeded(uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
} else if (messageEmbed?.type === 'invite') {
|
||||
const code = messageEmbed.code
|
||||
embed = {
|
||||
$type: 'chat.bsky.embed.joinLink',
|
||||
code,
|
||||
}
|
||||
|
||||
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
|
||||
if (joinLinkPreview) {
|
||||
embedView = {
|
||||
$type: 'chat.bsky.embed.joinLink#view',
|
||||
joinLinkPreview,
|
||||
}
|
||||
}
|
||||
|
||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
@@ -447,16 +424,7 @@ export function MessagesList({
|
||||
embedView,
|
||||
)
|
||||
},
|
||||
[
|
||||
agent,
|
||||
convoState,
|
||||
messageEmbed,
|
||||
getPost,
|
||||
getJoinLinkPreview,
|
||||
hasSession,
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
],
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
)
|
||||
|
||||
const scrollToEndOnPress = useCallback(() => {
|
||||
@@ -627,11 +595,11 @@ export function MessagesList({
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!messageEmbed}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embed={messageEmbed}
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
@@ -639,11 +607,11 @@ export function MessagesList({
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!messageEmbed}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embed={messageEmbed}
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {GlassView} from '#/components/GlassView'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS} from '#/env'
|
||||
|
||||
export function RequestStatus({
|
||||
top,
|
||||
count,
|
||||
onDismiss,
|
||||
onPress,
|
||||
}: {
|
||||
top: number
|
||||
count: number
|
||||
onDismiss: () => void
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200).delay(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_50,
|
||||
{
|
||||
top: top + (IS_LIQUID_GLASS ? tokens.space.sm : tokens.space.xl),
|
||||
left: tokens.space.xl,
|
||||
right: tokens.space.xl,
|
||||
},
|
||||
]}>
|
||||
<GlassView
|
||||
style={[a.flex_1, a.rounded_full, a.flex_row, a.align_center]}
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
tintColor={t.palette.primary_50}
|
||||
fallbackStyle={{
|
||||
backgroundColor: t.palette.primary_50,
|
||||
borderWidth: 1,
|
||||
borderColor: t.palette.primary_100,
|
||||
}}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`View incoming requests`}
|
||||
accessibilityHint={l`View incoming requests to join this group chat`}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.p_lg]}
|
||||
onPress={onPress}>
|
||||
<EnvelopeIcon size="md" fill={t.palette.primary_500} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.ml_sm,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
{count > JOIN_REQUESTS_THRESHOLD
|
||||
? l({
|
||||
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||
comment:
|
||||
'Displayed when the number of requests is greater than 20',
|
||||
})
|
||||
: plural(count, {
|
||||
one: '# new join request',
|
||||
other: '# new join requests',
|
||||
})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Close banner`}
|
||||
accessibilityHint={l`Close the incoming requests banner`}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={onDismiss}
|
||||
style={[a.p_lg]}>
|
||||
<CloseIcon size="md" fill={t.palette.primary_500} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -83,7 +83,6 @@ function Page({
|
||||
style={[a.w_full, a.aspect_square]}
|
||||
alt={alt}
|
||||
accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
{page === 1 && (
|
||||
<Image
|
||||
@@ -98,7 +97,6 @@ function Page({
|
||||
},
|
||||
]}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
alt={_(msg`Your profile picture`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
@@ -213,7 +212,7 @@ export function StepProfile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
image = await compressIfNeeded(image, IMAGE_SIZE_CONFIG_2K_1MB)
|
||||
image = await compressIfNeeded(image, 1000000)
|
||||
|
||||
// If we are on mobile, prefetching the image will load the image into memory before we try and display it,
|
||||
// stopping any brief flickers.
|
||||
|
||||
@@ -105,7 +105,6 @@ function GermLogo({size}: {size: 'small' | 'large'}) {
|
||||
source={require('../../../../assets/images/germ_logo.webp')}
|
||||
accessibilityIgnoresInvertColors={false}
|
||||
contentFit="cover"
|
||||
useAppleWebpCodec
|
||||
style={[
|
||||
a.rounded_full,
|
||||
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
|
||||
@@ -24,6 +26,7 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||
@@ -83,7 +86,7 @@ export function ProfileFeedHeaderSkeleton() {
|
||||
|
||||
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
const t = useTheme()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {hasSession} = useSession()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -118,7 +121,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
|
||||
if (savedFeedConfig) {
|
||||
await removeFeed(savedFeedConfig)
|
||||
Toast.show(l`Removed from your feeds`)
|
||||
Toast.show(_(msg`Removed from your feeds`))
|
||||
ax.metric('feed:unsave', {feedUrl: info.uri})
|
||||
} else {
|
||||
await addSavedFeeds([
|
||||
@@ -128,12 +131,14 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
pinned: false,
|
||||
},
|
||||
])
|
||||
Toast.show(l`Saved to your feeds`)
|
||||
Toast.show(_(msg`Saved to your feeds`))
|
||||
ax.metric('feed:save', {feedUrl: info.uri})
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
l`There was an issue updating your feeds, please check your internet connection and try again.`,
|
||||
_(
|
||||
msg`There was an issue updating your feeds, please check your internet connection and try again.`,
|
||||
),
|
||||
{
|
||||
type: 'error',
|
||||
},
|
||||
@@ -156,10 +161,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
])
|
||||
|
||||
if (pinned) {
|
||||
Toast.show(l`Pinned ${info.displayName} to Home`)
|
||||
Toast.show(_(msg`Pinned ${info.displayName} to Home`))
|
||||
ax.metric('feed:pin', {feedUrl: info.uri})
|
||||
} else {
|
||||
Toast.show(l`Unpinned ${info.displayName} from Home`)
|
||||
Toast.show(_(msg`Unpinned ${info.displayName} from Home`))
|
||||
ax.metric('feed:unpin', {feedUrl: info.uri})
|
||||
}
|
||||
} else {
|
||||
@@ -170,11 +175,11 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
Toast.show(l`Pinned ${info.displayName} to Home`)
|
||||
Toast.show(_(msg`Pinned ${info.displayName} to Home`))
|
||||
ax.metric('feed:pin', {feedUrl: info.uri})
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.show(l`There was an issue contacting the server`, {
|
||||
Toast.show(_(msg`There was an issue contacting the server`), {
|
||||
type: 'error',
|
||||
})
|
||||
logger.error('Failed to toggle pinned feed', {message: e})
|
||||
@@ -189,7 +194,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content align="left">
|
||||
<Button
|
||||
label={l`Open feed info screen`}
|
||||
label={_(msg`Open feed info screen`)}
|
||||
style={[
|
||||
a.justify_start,
|
||||
{
|
||||
@@ -290,12 +295,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
<Layout.Header.Slot>
|
||||
{isPinned ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={l`Open feed options menu`}>
|
||||
<Menu.Trigger label={_(msg`Open feed options menu`)}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
label={l`Open feed options menu`}
|
||||
label={_(msg`Open feed options menu`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
@@ -309,21 +314,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
<Menu.Outer>
|
||||
<Menu.Item
|
||||
disabled={isFeedStateChangePending}
|
||||
label={l`Unpin from home`}
|
||||
label={_(msg`Unpin from home`)}
|
||||
onPress={onTogglePinned}>
|
||||
<Menu.ItemText>{l`Unpin from home`}</Menu.ItemText>
|
||||
<Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={X} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={isFeedStateChangePending}
|
||||
label={
|
||||
isSaved ? l`Remove from my feeds` : l`Save to my feeds`
|
||||
isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)
|
||||
}
|
||||
onPress={onToggleSaved}>
|
||||
<Menu.ItemText>
|
||||
{isSaved
|
||||
? l`Remove from my feeds`
|
||||
: l`Save to my feeds`}
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isSaved ? Trash : Plus}
|
||||
@@ -334,7 +341,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
</Menu.Root>
|
||||
) : (
|
||||
<Button
|
||||
label={l`Pin to Home`}
|
||||
label={_(msg`Pin to Home`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
@@ -347,10 +354,11 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
</Layout.Center>
|
||||
|
||||
<Dialog.Outer control={infoControl}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Feed menu`}
|
||||
label={_(msg`Feed menu`)}
|
||||
style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
|
||||
<DialogInner
|
||||
info={info}
|
||||
@@ -385,12 +393,13 @@ function DialogInner({
|
||||
isFeedStateChangePending: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {hasSession} = useSession()
|
||||
const playHaptic = useHaptics()
|
||||
const control = Dialog.useDialogContext()
|
||||
const reportDialogControl = useReportDialogControl()
|
||||
const [rt] = useRichText(info.description.text)
|
||||
const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
|
||||
const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
|
||||
useUnlikeMutation()
|
||||
@@ -413,7 +422,9 @@ function DialogInner({
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
l`There was an issue contacting the server, please check your internet connection and try again.`,
|
||||
_(
|
||||
msg`There was an issue contacting the server, please check your internet connection and try again.`,
|
||||
),
|
||||
{
|
||||
type: 'error',
|
||||
},
|
||||
@@ -425,9 +436,9 @@ function DialogInner({
|
||||
const onPressShare = useCallback(() => {
|
||||
playHaptic()
|
||||
const url = toShareUrl(info.route.href)
|
||||
void shareUrl(url)
|
||||
shareUrl(url)
|
||||
ax.metric('feed:share', {feedUrl: info.uri})
|
||||
}, [ax, info, playHaptic])
|
||||
}, [info, playHaptic])
|
||||
|
||||
const onPressReport = useCallback(() => {
|
||||
reportDialogControl.open()
|
||||
@@ -451,7 +462,7 @@ function DialogInner({
|
||||
<Trans>
|
||||
By{' '}
|
||||
<InlineLinkText
|
||||
label={l`View ${info.creatorHandle}'s profile`}
|
||||
label={_(msg`View ${info.creatorHandle}'s profile`)}
|
||||
to={makeProfileLink({
|
||||
did: info.creatorDid,
|
||||
handle: info.creatorHandle,
|
||||
@@ -466,7 +477,7 @@ function DialogInner({
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={l`Share this feed`}
|
||||
label={_(msg`Share this feed`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -475,11 +486,13 @@ function DialogInner({
|
||||
<ButtonIcon icon={Share} size="lg" />
|
||||
</Button>
|
||||
</View>
|
||||
<RichText value={info.description} style={[a.text_md]} />
|
||||
|
||||
<RichText value={rt} style={[a.text_md]} />
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
{typeof likeCount === 'number' && (
|
||||
<InlineLinkText
|
||||
label={l`View users who like this feed`}
|
||||
label={_(msg`View users who like this feed`)}
|
||||
to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
|
||||
style={[a.underline, t.atoms.text_contrast_medium]}
|
||||
onPress={() => control.close()}>
|
||||
@@ -489,12 +502,13 @@ function DialogInner({
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{hasSession && (
|
||||
<>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
|
||||
<Button
|
||||
disabled={isLikePending || isUnlikePending}
|
||||
label={l`Like this feed`}
|
||||
label={_(msg`Like this feed`)}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={onToggleLiked}
|
||||
@@ -511,7 +525,7 @@ function DialogInner({
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isFeedStateChangePending}
|
||||
label={isPinned ? l`Unpin feed` : l`Pin feed`}
|
||||
label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
|
||||
size="small"
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
onPress={onTogglePinned}
|
||||
@@ -533,7 +547,7 @@ function DialogInner({
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
label={l`Report feed`}
|
||||
label={_(msg`Report feed`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {useState} from 'react'
|
||||
import {Alert, View} from 'react-native'
|
||||
import * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type ImageSourcePropType} from 'react-native'
|
||||
import type * as DynamicAppIcon from '@bsky.app/expo-dynamic-app-icon'
|
||||
import type * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
|
||||
|
||||
export type AppIconSet = {
|
||||
id: DynamicAppIcon.IconName
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user