Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey e8eac442b1 Disable overscroll on Android to fix settle discrepancy 2026-06-03 17:48:08 -05:00
219 changed files with 51434 additions and 84395 deletions
-2
View File
@@ -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
+10 -2
View File
@@ -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
+6 -2
View File
@@ -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: |
-1
View File
@@ -133,4 +133,3 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
+12 -23
View File
@@ -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,
-45
View File
@@ -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
View File
@@ -349,7 +349,7 @@ module.exports = function (_config) {
},
],
[
'@bsky.app/expo-dynamic-app-icon',
'@mozzius/expo-dynamic-app-icon',
{
/**
* Default set
+3 -3
View File
@@ -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)
+39
View File
@@ -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
@@ -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
View File
@@ -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",
+23 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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 -37
View File
@@ -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()
})
})
-42
View File
@@ -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,
-19
View File
@@ -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
}
+15 -13
View File
@@ -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])
+4
View File
@@ -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) {
+6 -25
View File
@@ -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
}
}
+3 -3
View File
@@ -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])
+3 -28
View File
@@ -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']
-1
View File
@@ -499,7 +499,6 @@ function TriggerClone({
accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false}
cachePolicy="none"
/>
</Animated.View>
)
+13 -8
View File
@@ -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>
)
}
+6 -18
View File
@@ -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 -11
View File
@@ -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
+16 -27
View File
@@ -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>
+2 -22
View File
@@ -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 => {
-10
View File
@@ -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<{
+2
View File
@@ -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}
/>
)
}
+3 -38
View File
@@ -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}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows you</Trans>
<Trans>Follows You</Trans>
</Text>
</View>
)
@@ -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>
)
}
+5 -47
View File
@@ -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,
+1 -23
View File
@@ -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}
/>
)}
</>
-7
View File
@@ -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 & {
+6 -11
View File
@@ -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
+1 -3
View File
@@ -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
View File
@@ -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
-103
View File
@@ -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>
)
}
-49
View File
@@ -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>
)
}
-146
View File
@@ -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>
)
}
-8
View File
@@ -1,8 +0,0 @@
export {Card} from './Card'
export {
type ChatInviteAction,
type ChatInviteContextValue,
useChatInvite,
} from './Context'
export {JoinButton} from './JoinButton'
export {Root} from './Root'
+65 -57
View File
@@ -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>
+3 -36
View File
@@ -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`
+5 -15
View File
@@ -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`}
+2 -15
View File
@@ -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}
+2 -18
View File
@@ -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)}
/>
)}
+15 -2
View File
@@ -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} cant 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} cant be added</Trans>
</Text>
)}
</View>
{enabled ? <Toggle.Checkbox /> : null}
</>
)}
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
</Toggle.Item>
)
}
-55
View File
@@ -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}
}
-1
View File
@@ -142,7 +142,6 @@ export function AutoSizedImage({
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder />
+6 -49
View File
@@ -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()
+7 -15
View File
@@ -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 cant 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
}
+7 -11
View File
@@ -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[]> {
-25
View File
@@ -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 {
+3 -4
View File
@@ -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]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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