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/chat/abcdefg', 'abcdefg'],
|
||||
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
|
||||
// http is not recognized as a bsky.app url
|
||||
['http://bsky.app/chat/abcdefg', undefined],
|
||||
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
|
||||
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
|
||||
['/chat/abcdefg', 'abcdefg'],
|
||||
['/chat/abcdefg?utm=foo', 'abcdefg'],
|
||||
['/chat/abcdefg#section', 'abcdefg'],
|
||||
|
||||
// too short
|
||||
['https://bsky.app/chat/abcdef', undefined],
|
||||
['/chat/abcdef', undefined],
|
||||
// too long
|
||||
['https://bsky.app/chat/abcdefghijk', undefined],
|
||||
['/chat/abcdefghijk', undefined],
|
||||
// invalid characters
|
||||
['https://bsky.app/chat/abc-def', undefined],
|
||||
['/chat/abc def', undefined],
|
||||
// trailing path
|
||||
['https://bsky.app/chat/abcdefg/extra', undefined],
|
||||
['/chat/abcdefg/extra', undefined],
|
||||
// wrong path
|
||||
['https://bsky.app/profile/abcdefg', undefined],
|
||||
['https://bsky.app/chat', undefined],
|
||||
// wrong host
|
||||
['https://example.com/chat/abcdefg', undefined],
|
||||
// not a url, not a path
|
||||
['chat/abcdefg', undefined],
|
||||
['abcdefg', undefined],
|
||||
['', undefined],
|
||||
// malformed url
|
||||
['https://[invalid/chat/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
|
||||
|
||||
@@ -88,7 +88,7 @@ func serve(cctx *cli.Context) error {
|
||||
Host: appviewHost,
|
||||
}
|
||||
|
||||
// optional client for the chat appview, used by /chat/<code> for OG previews.
|
||||
// optional client for the chat appview, used by /c/<code> for OG previews.
|
||||
var chatXrpcc *xrpc.Client
|
||||
if chatHost != "" {
|
||||
chatXrpcc = &xrpc.Client{
|
||||
@@ -367,7 +367,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// chat invites
|
||||
e.GET("/chat/:code", server.WebChatInvite)
|
||||
e.GET("/c/:code", server.WebChatInvite)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
@@ -695,7 +695,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
|
||||
|
||||
data["title"] = preview.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
|
||||
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
|
||||
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
|
||||
}
|
||||
return c.Render(http.StatusOK, "chatinvite.html", data)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1773,6 +1802,16 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/state/queries/messages/accept-conversation.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/messages/update-all-read.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/state/queries/my-lists.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
|
||||
+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
-1
@@ -804,7 +804,7 @@ const LINKING = {
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
|
||||
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
|
||||
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
|
||||
// path to Home so the dialog overlays Home instead of NotFound. On native,
|
||||
// react-navigation strips the `bluesky://` prefix and passes the path
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -1175,37 +1175,18 @@ export type Events = {
|
||||
'profile:associated:germ:self-disconnect': {}
|
||||
'profile:associated:germ:self-reconnect': {}
|
||||
|
||||
// Post photo embed events
|
||||
'post:photoEmbed:impression': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:photoEmbed:open': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
fromImage: number
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:photoEmbed:carouselSwipe': {
|
||||
// Gallery carousel events
|
||||
'post:gallery:swipe': {
|
||||
fromImage: number
|
||||
toImage: number
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
'post:photoEmbed:lightboxSwipe': {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
'post:gallery:openLightbox': {
|
||||
fromImage: number
|
||||
toImage: number
|
||||
totalImages: number
|
||||
}
|
||||
'post:gallery:impression': {
|
||||
totalImages: number
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function AvatarBubbles({
|
||||
moderationOpts,
|
||||
}: {
|
||||
animate?: boolean
|
||||
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
/**
|
||||
* By default, when there are more than 2 profiles, the current user is
|
||||
* filtered out (so you don't see yourself among your own group's members).
|
||||
@@ -50,12 +50,12 @@ export function AvatarBubbles({
|
||||
const {currentAccount} = useSession()
|
||||
const profiles =
|
||||
!self && allProfiles.length > 2
|
||||
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
||||
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
|
||||
: allProfiles
|
||||
const moderations = useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
return profiles.map(p => {
|
||||
return p && moderateProfile(p, moderationOpts)
|
||||
return moderateProfile(p, moderationOpts)
|
||||
})
|
||||
}, [profiles, moderationOpts])
|
||||
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Error({
|
||||
@@ -13,20 +15,22 @@ export function Error({
|
||||
onRetry,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<Layout.Center
|
||||
<CenteredView
|
||||
style={[
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
@@ -34,7 +38,8 @@ export function Error({
|
||||
!gtMobile && a.justify_between,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}>
|
||||
]}
|
||||
sideBorders={sideBorders}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
||||
<Text
|
||||
@@ -53,7 +58,7 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={l`Press to retry`}
|
||||
label={_(msg`Press to retry`)}
|
||||
onPress={onRetry}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
@@ -65,7 +70,7 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={l`Return to previous page`}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={goBack}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
@@ -74,6 +79,6 @@ export function Error({
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</Layout.Center>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,14 +32,12 @@ 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'
|
||||
import {useTheme} from '#/alf'
|
||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||
import {type Lightbox} from '#/components/Lightbox/state'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||
import {Footer} from '../chrome/Footer'
|
||||
@@ -138,9 +136,6 @@ export default function ImageViewRoot({
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -229,8 +224,7 @@ function ImageView({
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
const {images, index: initialImageIndex, metricsContext} = lightbox
|
||||
const ax = useAnalytics()
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -379,21 +373,7 @@ function ImageView({
|
||||
scrollEnabled={!isScaled}
|
||||
initialPage={initialImageIndex}
|
||||
onPageSelected={e => {
|
||||
const next = e.nativeEvent.position
|
||||
setImageIndex(prev => {
|
||||
if (metricsContext && prev !== next) {
|
||||
ax.metric('post:photoEmbed:lightboxSwipe', {
|
||||
layout: metricsContext.layout,
|
||||
fromImage: prev + 1,
|
||||
toImage: next + 1,
|
||||
totalImages: images.length,
|
||||
postUri: metricsContext.postUri,
|
||||
postAuthorDid: metricsContext.postAuthorDid,
|
||||
feedDescriptor: metricsContext.feedDescriptor,
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
setImageIndex(e.nativeEvent.position)
|
||||
setIsScaled(false)
|
||||
}}
|
||||
onPageScrollStateChanged={e => {
|
||||
|
||||
@@ -11,20 +11,10 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||
import {type ImageSource} from '#/components/Lightbox/types'
|
||||
|
||||
export type LightboxMetricsContext = {
|
||||
layout: 'single' | 'grid' | 'carousel'
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
|
||||
export type Lightbox = {
|
||||
id: string
|
||||
images: ImageSource[]
|
||||
index: number
|
||||
// Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe.
|
||||
// Left unset for non-post contexts (e.g. profile avatar/banner lightbox).
|
||||
metricsContext?: LightboxMetricsContext
|
||||
}
|
||||
|
||||
const LightboxContext = createContext<{
|
||||
|
||||
@@ -185,6 +185,7 @@ let ListMaybePlaceholder = ({
|
||||
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
hideBackButton={hideBackButton}
|
||||
/>
|
||||
)
|
||||
@@ -225,6 +226,7 @@ let ListMaybePlaceholder = ({
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
hideBackButton={hideBackButton}
|
||||
sideBorders={sideBorders}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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/chat/<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,16 +2,12 @@ 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'
|
||||
import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {
|
||||
type LightboxMetricsContext,
|
||||
useLightboxControls,
|
||||
} from '#/components/Lightbox/state'
|
||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
@@ -19,43 +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 layout: 'single' | 'grid' | 'carousel' =
|
||||
images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid'
|
||||
|
||||
const postContext = rest.post
|
||||
? {
|
||||
postUri: rest.post.uri,
|
||||
postAuthorDid: rest.post.author.did,
|
||||
feedDescriptor: rest.feedDescriptor,
|
||||
}
|
||||
: undefined
|
||||
const metricsContext: LightboxMetricsContext | undefined = postContext
|
||||
? {layout, ...postContext}
|
||||
: undefined
|
||||
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.
|
||||
@@ -74,14 +43,6 @@ export function ImageEmbed({
|
||||
refs: AnimatedRef<any>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
if (postContext) {
|
||||
ax.metric('post:photoEmbed:open', {
|
||||
layout,
|
||||
fromImage: index + 1,
|
||||
totalImages: images.length,
|
||||
...postContext,
|
||||
})
|
||||
}
|
||||
openLightbox({
|
||||
images: items.map((item, i) => ({
|
||||
...item,
|
||||
@@ -92,7 +53,6 @@ export function ImageEmbed({
|
||||
type: 'image',
|
||||
})),
|
||||
index,
|
||||
metricsContext,
|
||||
})
|
||||
}
|
||||
const onPressIn = (_: number) => {
|
||||
@@ -149,7 +109,7 @@ export function ImageEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
if (useExpandedLayout) {
|
||||
if (galleryEnabled) {
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<Gallery
|
||||
@@ -158,7 +118,6 @@ export function ImageEmbed({
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
metricsPostContext={postContext}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -171,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')}
|
||||
@@ -345,9 +326,6 @@ export function QuoteEmbed({
|
||||
allowNestedQuotes={
|
||||
parentIsWithinQuote ? false : parentAllowNestedQuotes
|
||||
}
|
||||
// The photo embed belongs to the quoted post, so attribute its
|
||||
// analytics to the quoted post rather than the parent.
|
||||
post={quote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,13 +15,6 @@ export type CommonProps = {
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
allowNestedQuotes?: boolean
|
||||
/**
|
||||
* The post that contains this embed. Used for analytics on photo embed
|
||||
* events (post:photoEmbed:*). When the embed has no owning post (e.g.
|
||||
* composer previews), leave this undefined and no events will be emitted.
|
||||
*/
|
||||
post?: AppBskyFeedDefs.PostView
|
||||
feedDescriptor?: string
|
||||
}
|
||||
|
||||
export type EmbedProps = CommonProps & {
|
||||
|
||||
@@ -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 {Check_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/chat/${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'
|
||||
@@ -1,6 +1,6 @@
|
||||
import {memo, useCallback} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {type ModerationCause} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -16,18 +16,13 @@ import {
|
||||
unstableCacheProfileView,
|
||||
useProfileBlockMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type ViewStyleProp} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {
|
||||
type ConvoWithDetails,
|
||||
getConvoReportSubject,
|
||||
} from '#/components/dms/util'
|
||||
import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
|
||||
@@ -44,6 +39,7 @@ import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {AfterReportConversationDialog} from './AfterReportConversationDialog'
|
||||
|
||||
let ConvoMenu = ({
|
||||
convo,
|
||||
@@ -53,9 +49,10 @@ let ConvoMenu = ({
|
||||
showMarkAsRead,
|
||||
hideTrigger,
|
||||
blockInfo,
|
||||
latestReportableMessage,
|
||||
style,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
control?: Menu.MenuControlProps
|
||||
currentScreen: 'list' | 'conversation'
|
||||
@@ -65,21 +62,20 @@ let ConvoMenu = ({
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
latestReportableMessage?: ChatBskyConvoDefs.MessageView
|
||||
style?: ViewStyleProp['style']
|
||||
}): React.ReactNode => {
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const leaveConvoControl = Prompt.usePromptControl()
|
||||
const reportControl = Prompt.usePromptControl()
|
||||
const blockedByListControl = Prompt.usePromptControl()
|
||||
const afterReportControl = Prompt.usePromptControl()
|
||||
const blockOrDeleteControl = Prompt.usePromptControl()
|
||||
const deleteControl = Prompt.usePromptControl()
|
||||
|
||||
const {listBlocks} = blockInfo
|
||||
|
||||
const reportSubject = getConvoReportSubject(convo, currentAccount?.did)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
@@ -112,7 +108,6 @@ let ConvoMenu = ({
|
||||
showMarkAsRead={showMarkAsRead}
|
||||
blockInfo={blockInfo}
|
||||
convo={convo}
|
||||
canReport={!!reportSubject}
|
||||
leaveConvoControl={leaveConvoControl}
|
||||
reportControl={reportControl}
|
||||
blockedByListControl={blockedByListControl}
|
||||
@@ -121,37 +116,54 @@ let ConvoMenu = ({
|
||||
</Menu.Root>
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
convoId={convo.view.id}
|
||||
convoId={convo.id}
|
||||
currentScreen={currentScreen}
|
||||
/>
|
||||
{reportSubject && (
|
||||
<ReportDialog
|
||||
subject={reportSubject}
|
||||
control={reportControl}
|
||||
onAfterSubmit={() => {
|
||||
unstableCacheProfileView(queryClient, profile)
|
||||
afterReportControl.open()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{convo.kind === 'group' ? (
|
||||
<AfterReportConversationDialog
|
||||
control={afterReportControl}
|
||||
currentScreen={currentScreen}
|
||||
params={{
|
||||
convoId: convo.view.id,
|
||||
did: profile.did,
|
||||
}}
|
||||
/>
|
||||
{latestReportableMessage ? (
|
||||
<>
|
||||
<ReportDialog
|
||||
subject={{
|
||||
view: 'convo',
|
||||
convoId: convo.id,
|
||||
message: latestReportableMessage,
|
||||
}}
|
||||
control={reportControl}
|
||||
onAfterSubmit={() => {
|
||||
const sender = convo.members.find(
|
||||
member => member.did === latestReportableMessage.sender.did,
|
||||
)
|
||||
if (sender) {
|
||||
unstableCacheProfileView(queryClient, sender)
|
||||
}
|
||||
blockOrDeleteControl.open()
|
||||
}}
|
||||
/>
|
||||
<AfterReportDialog
|
||||
control={blockOrDeleteControl}
|
||||
currentScreen={currentScreen}
|
||||
params={{
|
||||
convoId: convo.id,
|
||||
did: latestReportableMessage.sender.did,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AfterReportDialog
|
||||
control={afterReportControl}
|
||||
currentScreen={currentScreen}
|
||||
params={{
|
||||
convoId: convo.view.id,
|
||||
did: profile.did,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ReportConversationDialog
|
||||
control={reportControl}
|
||||
convoId={convo.id}
|
||||
did={profile.did}
|
||||
onAfterSubmit={deleteControl.open}
|
||||
/>
|
||||
<AfterReportConversationDialog
|
||||
control={deleteControl}
|
||||
currentScreen={currentScreen}
|
||||
params={{
|
||||
convoId: convo.id,
|
||||
did: profile.did,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<BlockedByListDialog
|
||||
control={blockedByListControl}
|
||||
@@ -165,16 +177,14 @@ ConvoMenu = memo(ConvoMenu)
|
||||
function MenuContent({
|
||||
convo: initialConvo,
|
||||
profile,
|
||||
canReport,
|
||||
showMarkAsRead,
|
||||
blockInfo,
|
||||
leaveConvoControl,
|
||||
reportControl,
|
||||
blockedByListControl,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
canReport: boolean
|
||||
showMarkAsRead?: boolean
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
@@ -191,9 +201,9 @@ function MenuContent({
|
||||
const {listBlocks, userBlock} = blockInfo
|
||||
const isBlocking = userBlock || !!listBlocks.length
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
const isGroupConvo = initialConvo.kind === 'group'
|
||||
const isGroupConvo = ChatBskyConvoDefs.isGroupConvo(initialConvo.kind)
|
||||
|
||||
const convoId = initialConvo.view.id
|
||||
const convoId = initialConvo.id
|
||||
const {data: convo} = useConvoQuery({convoId})
|
||||
|
||||
const onNavigateToProfile = useCallback(() => {
|
||||
@@ -289,17 +299,15 @@ function MenuContent({
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canReport && (
|
||||
<Menu.Item
|
||||
destructive
|
||||
label={l`Report conversation`}
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
<Menu.ItemText>
|
||||
<Trans>Report conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
destructive
|
||||
label={l`Report conversation`}
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
<Menu.ItemText>
|
||||
<Trans>Report conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
} from 'react'
|
||||
import {LayoutAnimation, type TextInput, View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
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,23 +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.{' '}
|
||||
<Plural
|
||||
value={MAX_GROUP_NAME_GRAPHEME_LENGTH}
|
||||
other="The maximum number of characters is #."
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<UserSearchInput
|
||||
@@ -648,8 +622,6 @@ export function InitiateChatFlow({
|
||||
handleButtonPress,
|
||||
buttonText,
|
||||
groupName,
|
||||
groupNameTooLong,
|
||||
t.palette.negative_400,
|
||||
searchText,
|
||||
control,
|
||||
showChatProfileTabs,
|
||||
@@ -692,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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -8,6 +12,7 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -83,6 +88,7 @@ function ProfileHeaderReady({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const profile = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
@@ -104,6 +110,12 @@ function ProfileHeaderReady({
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
const handle = isDeletedAccount ? null : sanitizeHandle(profile.handle, '@')
|
||||
|
||||
const latestReportableMessage =
|
||||
ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) &&
|
||||
convo.view.lastMessage.sender?.did !== currentAccount?.did
|
||||
? convo.view.lastMessage
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
heading={
|
||||
@@ -139,10 +151,11 @@ function ProfileHeaderReady({
|
||||
}
|
||||
settings={
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
convo={convo.view}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||
@@ -48,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()
|
||||
@@ -241,41 +224,3 @@ export function parseConvoView(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the report subject for a conversation-level "Report conversation"
|
||||
* action (as opposed to reporting an individual message, which always reports
|
||||
* that message + its sender).
|
||||
*
|
||||
* - group: always report the whole convo, targeting the owner. Returns null if
|
||||
* the owner has left, in which case there is nothing to report against.
|
||||
* - direct: report the last reportable message if there is one (i.e. the last
|
||||
* message exists and wasn't sent by us), otherwise report the whole convo
|
||||
* targeting the other user.
|
||||
*/
|
||||
export function getConvoReportSubject(
|
||||
convo: ConvoWithDetails,
|
||||
ownDid: string | undefined,
|
||||
): ReportSubject | null {
|
||||
if (convo.kind === 'group') {
|
||||
if (!convo.primaryMember) return null
|
||||
return {convoId: convo.view.id, did: convo.primaryMember.did}
|
||||
}
|
||||
|
||||
const lastMessage = convo.view.lastMessage
|
||||
const reportableMessage =
|
||||
ChatBskyConvoDefs.isMessageView(lastMessage) &&
|
||||
lastMessage.sender?.did !== ownDid
|
||||
? lastMessage
|
||||
: null
|
||||
|
||||
if (reportableMessage) {
|
||||
return {
|
||||
view: 'convo',
|
||||
convoId: convo.view.id,
|
||||
message: reportableMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return {convoId: convo.view.id, did: convo.primaryMember.did}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ export function AutoSizedImage({
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
|
||||
@@ -55,13 +55,6 @@ interface GalleryProps {
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
// Post context for the in-feed carousel swipe metric. Omit for non-post
|
||||
// contexts (no event will be emitted).
|
||||
metricsPostContext?: {
|
||||
postUri: string
|
||||
postAuthorDid: string
|
||||
feedDescriptor?: string
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
@@ -106,7 +99,6 @@ export function Gallery({
|
||||
onPressIn,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
metricsPostContext,
|
||||
}: GalleryProps) {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
@@ -177,17 +169,13 @@ export function Gallery({
|
||||
const emitSwipeMetric = useMemo(
|
||||
() =>
|
||||
debounce((fromIndex: number, toIndex: number) => {
|
||||
if (!metricsPostContext) return
|
||||
ax.metric('post:photoEmbed:carouselSwipe', {
|
||||
ax.metric('post:gallery:swipe', {
|
||||
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
|
||||
toImage: toIndex + 1, // convert to 1-based index for easier analysis
|
||||
totalImages: images.length,
|
||||
postUri: metricsPostContext.postUri,
|
||||
postAuthorDid: metricsPostContext.postAuthorDid,
|
||||
feedDescriptor: metricsPostContext.feedDescriptor,
|
||||
})
|
||||
}, 200),
|
||||
[ax, images.length, metricsPostContext],
|
||||
[ax, images.length],
|
||||
)
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
@@ -289,6 +277,10 @@ export function Gallery({
|
||||
renderItem={({item, index}) => {
|
||||
const openLightboxAtIndex = onPress
|
||||
? () => {
|
||||
ax.metric('post:gallery:openLightbox', {
|
||||
fromImage: index + 1, // convert to 1-based index for easier analysis
|
||||
totalImages: images.length,
|
||||
})
|
||||
const refs: AnimatedRef<any>[] = []
|
||||
const dims: (Dimensions | null)[] = []
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
@@ -351,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,
|
||||
@@ -496,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,8 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
|
||||
export const CHAT_INVITE_CODE_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/
|
||||
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
||||
|
||||
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
||||
let pathname: string
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
+1030
-1358
File diff suppressed because it is too large
Load Diff
+1053
-1470
File diff suppressed because it is too large
Load Diff
+1028
-1356
File diff suppressed because it is too large
Load Diff
+1053
-1470
File diff suppressed because it is too large
Load Diff
+1053
-1470
File diff suppressed because it is too large
Load Diff
+1053
-1470
File diff suppressed because it is too large
Load Diff
+1044
-1372
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1047
-1375
File diff suppressed because it is too large
Load Diff
+1105
-1433
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user