Compare commits

...

60 Commits

Author SHA1 Message Date
Samuel Newman d84a8d5819 try and resolve loading bug once and for all 2026-06-08 21:18:51 +03:00
Samuel Newman 9b5fc19613 [Chat] Centralize conversation report-subject resolution (#10754)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:07:35 -07:00
Spence Pope 0090285fc0 Refactor photo embed analytics to post:photoEmbed:* namespace (#10784)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:44:59 -04:00
Samuel Newman c4f3a2cb89 [Chat] Rename invite route to /chat/ (#10782) 2026-06-08 09:26:23 -07:00
Samuel Newman d9345cb1cf [Chat] Fix footer logic (#10785) 2026-06-08 09:20:37 -07:00
DS Boyce 5c0429a3f7 Block posting of invalid chat invites (#10757)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-08 05:28:24 -07:00
Samuel Newman 16046cd85e [Chat] Invite link dialog tweaks (#10750) 2026-06-08 05:27:58 -07:00
Samuel Newman eb79c4d53c Show accept/reject footer for empty request convos (#10781)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:10:41 -07:00
DS Boyce 6997d220a0 Add Leave chat button to settings for chat owners (#10764)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-08 04:57:08 -07:00
DS Boyce e60e31733a Use new listConvoRequests endpoint (#10755)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-08 04:36:34 -07:00
DS Boyce c02f2a1eda Fix alignment of error screen (#10763) 2026-06-08 03:51:11 -07:00
Samuel Newman 35258f1a16 [Chat] Fix overflowing chat names (#10780) 2026-06-08 03:50:44 -07:00
surfdude29 753015b59f Add plural formatting to "Group name is too long" strings (#10769) 2026-06-08 02:42:50 -07:00
Eric Bailey 0ce2ada9b0 Release 1.123.0 (#10766) 2026-06-06 10:26:44 -05:00
Eric Bailey 7577933378 Fix draft image pres with 4+ images (#10765) 2026-06-06 09:55:39 -05:00
estrattonbailey b9cd7e992a Nightly source-language update 2026-06-05 22:59:53 +00:00
surfdude29 a253e877fc Add plural formatting for error toast (#10756) 2026-06-05 17:57:57 -05:00
Eric Bailey 11dd259716 [Drafts] Always write embedGallery for image embeds (#10761)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:56:08 -05:00
Eric Bailey 0cc6d49388 Android share intent: logging + temp file cleanup (#10762)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:35:21 -05:00
Samuel Newman 5eeb80fe96 Fix Android crash when sharing unreadable media into the app (#10674) 2026-06-06 01:03:29 +03:00
DS Boyce a675cbacb6 Link to owner's profile from link embed (#10759) 2026-06-05 14:26:30 -07:00
DS Boyce 1d5fb89c80 Improve error messages for LeaveConvoPrompt (#10760) 2026-06-05 14:19:48 -07:00
Samuel Newman ac63ee7739 Remove useWindowDimensions from ProfileBadges (#10738) 2026-06-05 16:17:05 -05:00
Samuel Newman a5338090cb Let specified facets take priority in feed descriptions (#10740) 2026-06-05 16:13:26 -05:00
Samuel Newman fb8316fd85 Fix external card long press on web (#10739) 2026-06-05 16:04:21 -05:00
Samuel Newman b236f274c3 [Chat] Enforce limits for group size (#10753) 2026-06-05 23:17:29 +03:00
Samuel Newman 2e9bfd363c [Chat] Put pointer_events_none on chat bubbles in ChatListItem (#10752) 2026-06-05 13:09:55 -07:00
Samuel Newman 32c0c0ec83 "Follows You" string dedupe (#10751) 2026-06-05 13:08:39 -07:00
Samuel Newman 6d468c8ca4 [CI] Remove Wandalen/wretry.action, call pnpm install directly (#10745) 2026-06-05 22:49:02 +03:00
Samuel Newman a5aa985b33 Fix feed scroll reset on device rotation (#10737) 2026-06-05 22:48:34 +03:00
Eric Bailey fd8df7e201 Fire embed:standardSite:view from feed viewability, not embed mount (#10736)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:50:16 -05:00
Samuel Newman f8aae4a192 [Chat] Disable font scaling on fixed-height chat invite cards (#10744)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:18:27 +03:00
Samuel Newman 67ed59fbcd Align group invite settings with eligibility logic (#10748)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:10:14 +03:00
Samuel Newman 7d4d7642fd [Chat] Sync single-convo cache on chat lock firehose events (#10746)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:39:23 -07:00
Samuel Newman f81bb6f494 [Chat] Fix navigation stacking on messages links in split view (#10741)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:38:48 -07:00
Samuel Newman 3cdc0604ca [Chat] Fix stale reactions dialog after removing a reaction (#10743)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:38:23 -07:00
Samuel Newman d3f073b3d2 [Chat] Request screen tweaks (#10747) 2026-06-05 06:35:06 -07:00
estrattonbailey 96e321dfd9 Nightly source-language update 2026-06-05 02:21:15 +00:00
Spence Pope 68da797180 Add OTA fallback for gallery embed (#10734)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 21:19:35 -05:00
Eric Bailey 2f61c0a7b4 Gate StandardSite subscribe button custom theme on AAA contrast (#10735)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:51:34 -05:00
Samuel Newman f84c85aac6 Use Reanimated layout animations for Android composer (#8546) 2026-06-05 00:37:42 +03:00
Spence Pope 144ba30ea6 Add gallery embed type support (display + compose) (#10707) 2026-06-04 17:22:40 -04:00
DS Boyce de926d1838 Show a banner when there are incoming chat requests (#10498)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-04 14:22:29 -07:00
DS Boyce 45bd5b45e8 Reduce stale time for join link preview queries (#10733) 2026-06-04 14:11:50 -07:00
Eric Bailey 923e1635d8 V smol log cleanups (#10731) 2026-06-04 15:56:37 -05:00
Samuel Newman ded2ca3744 [Chat] Add chat invite as message embed type (#10728) 2026-06-04 13:11:03 -07:00
DS Boyce 09fa93553b Fix useJoinRequestMutation (#10730) 2026-06-04 13:08:51 -07:00
DS Boyce b9637c0305 Improve error message for group chat join link (#10729) 2026-06-04 12:57:22 -07:00
Spence Pope 6677ae977c Show image count badge on in-feed carousel (#10719) 2026-06-04 14:25:35 -04:00
Spence Pope 58783ca5ae ensure alt text is scrollable (#10671)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 11:24:50 -05:00
Spence Pope d3c9ed027c Fix web carousel horizontal swipe triggering browser back/forward (#10718)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-04 11:23:16 -05:00
Eric Bailey 795d493dea Apply per-use-case image size configs (#10690)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:04 -05:00
Samuel Newman 66db349ef2 Switch dynamic app icon library to @bsky.app, fix build flakiness (#10724) 2026-06-04 18:46:49 +03:00
DS Boyce 08cd4e58b0 Add group chat join links to supported embed types (#10454) 2026-06-04 08:11:50 -07:00
Eric Bailey c79b5bfa08 Disable overscroll on Android to fix settle discrepancy (#10717) 2026-06-04 09:29:36 -05:00
heikadog 49f8e5031f Fix labeller highlight when selected on bottom bar (#10716) 2026-06-04 03:28:40 -07:00
surfdude29 9f200aab05 Use correct Plural macro in InviteLinkDialog (#10723) 2026-06-04 00:51:51 -07:00
surfdude29 a19d652a39 Fix invalid zero plural category and add plural formatting (#10722) 2026-06-03 23:50:56 -07:00
pfrazee ad4591eb29 Nightly source-language update 2026-06-04 03:21:17 +00:00
Samuel Newman 153772b760 expo-image tweaks (#10710) 2026-06-04 01:03:11 +03:00
219 changed files with 84406 additions and 51442 deletions
+2
View File
@@ -20,6 +20,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -37,6 +38,7 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+2 -10
View File
@@ -57,11 +57,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
@@ -99,11 +95,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
run: pnpm intl:build
- name: Run tests
@@ -26,11 +26,7 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
run: pnpm install --frozen-lockfile
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
+2 -6
View File
@@ -34,12 +34,8 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
- name: Verify pnpm-lock.yaml
run: |
+1
View File
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
+23 -12
View File
@@ -1,11 +1,12 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import {
downloadAndResize,
type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip'
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = {
path: 'file://resized-image.jpg',
@@ -41,10 +42,8 @@ describe('downloadAndResize', () => {
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
width: 100,
height: 100,
maxDimension: 2000,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -60,9 +59,11 @@ describe('downloadAndResize', () => {
// First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String),
[{resize: {height: opts.height, width: opts.width}}],
[{resize: {height: 100, width: 100}}],
{format: SaveFormat.JPEG, compress: 1.0},
)
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
@@ -73,10 +74,8 @@ describe('downloadAndResize', () => {
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
width: 100,
height: 100,
maxDimension: 2000,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
@@ -90,13 +89,19 @@ describe('downloadAndResize', () => {
width: 1200,
height: 1000,
}
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const initialDimensionsTwo = {
width: 1000,
height: 1200,
}
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
expect(resizedDimensionsOne).toEqual(initialDimensionsOne)
expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo)
@@ -107,13 +112,19 @@ describe('downloadAndResize', () => {
width: 3000,
height: 1500,
}
const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne)
const resizedDimensionsOne = getResizedDimensions(
initialDimensionsOne,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
const initialDimensionsTwo = {
width: 2000,
height: 4000,
}
const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo)
const resizedDimensionsTwo = getResizedDimensions(
initialDimensionsTwo,
IMAGE_SIZE_CONFIG_2K_1MB.maxDimension,
)
expect(resizedDimensionsOne).toEqual({
width: 2000,
+45
View File
@@ -1,6 +1,7 @@
import {describe, expect, it} from '@jest/globals'
import {
getChatInviteCodeFromUrl,
isPossiblyAUrl,
isTrustedUrl,
linkRequiresWarning,
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
expect(output).toEqual(expected)
})
})
describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/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) {
},
],
[
'@mozzius/expo-dynamic-app-icon',
'@bsky.app/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 /c/<code> for OG previews.
// optional client for the chat appview, used by /chat/<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("/c/:code", server.WebChatInvite)
e.GET("/chat/: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 /c/:code.
// bskyogcard registers this route as /chat-invite/:code, not /chat/: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,9 +43,6 @@
}
},
"src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
@@ -124,11 +121,6 @@
"count": 1
}
},
"src/components/Button.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -261,11 +253,6 @@
"count": 3
}
},
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -274,11 +261,6 @@
"count": 1
}
},
"src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -811,14 +793,6 @@
"count": 1
}
},
"src/lib/api/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 3
}
},
"src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -1372,9 +1346,6 @@
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 5
}
@@ -1802,16 +1773,6 @@
"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,6 +6,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -13,6 +14,8 @@ import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents"
enum class AttachmentType {
IMAGE,
VIDEO,
@@ -119,17 +122,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris: List<Uri>,
text: String?,
) {
var allParams = ""
// Some URIs we receive may be unreadable (revoked permission, deleted file,
// a provider that rejects the read). Skip those rather than crashing the
// whole app, since this runs synchronously on the module init path.
val allParams =
uris
.mapNotNull { uri -> getImageInfo(uri) }
.joinToString(",") { info -> buildUriData(info) }
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
if (index < uris.count() - 1) {
allParams = "$allParams,"
}
}
if (allParams.isEmpty()) return
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -158,12 +159,30 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
val file = createFile(extension)
val out = FileOutputStream(file)
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Bail rather than crashing the whole
// app, since this runs synchronously on the module init path.
try {
FileOutputStream(file).use { out ->
val input =
appContext.currentActivity?.contentResolver?.openInputStream(uri)
?: run {
file.delete()
return
}
input.use { it.copyTo(out) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy shared video to cache", e)
file.delete()
return
}
val info = getVideoInfo(uri) ?: return
val info =
getVideoInfo(uri) ?: run {
file.delete()
return
}
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -176,15 +195,29 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
private fun getImageInfo(uri: Uri): Map<String, Any>? {
val bitmap =
try {
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
} catch (e: Exception) {
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Skip this image rather than crash.
Log.w(TAG, "Failed to read shared image", e)
return null
} ?: return null
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = createFile("jpeg")
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to write shared image to cache", e)
file.delete()
return null
}
return mapOf(
"width" to bitmap.width,
@@ -195,10 +228,19 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
val width: Int?
val height: Int?
try {
retriever.setDataSource(appContext.currentActivity, uri)
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} catch (e: Exception) {
// The URI may be unreadable or not a valid media source. Skip rather than crash.
Log.w(TAG, "Failed to read shared video metadata", e)
return null
} finally {
retriever.release()
}
if (width == null || height == null) {
return null
+2 -2
View File
@@ -93,11 +93,12 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.8",
"@atproto/api": "0.20.9",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
@@ -123,7 +124,6 @@
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
+23 -23
View File
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.8
version: 0.20.8
specifier: 0.20.9
version: 0.20.9
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -256,6 +256,9 @@ importers:
'@bsky.app/alf':
specifier: ^0.1.14
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon':
specifier: ^1.8.5
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-guess-language':
specifier: ^0.2.8
version: 0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -331,9 +334,6 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.0
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.8':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
'@atproto/api@0.20.9':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1624,6 +1624,13 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/expo-dynamic-app-icon@1.8.5':
resolution: {integrity: sha512-yLpd7XEEiXWpVrh81mhpZx2WrX2WwrJFEV81lNxnX61Cp6yG+ZKX3Oz1v58vpyL5p0I38njVUJ9s+n5NR4EjNw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@bsky.app/expo-guess-language@0.2.8':
resolution: {integrity: sha512-krcQfMSJn39kaFRpaOWxLUW9rT04reoBqjQviu2fTGQWXWEImG25SJondSObVNyGXlmRMrltt72Sc+aRPpQeog==}
peerDependencies:
@@ -2327,13 +2334,6 @@ packages:
'@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
'@mozzius/expo-dynamic-app-icon@1.8.1':
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
peerDependencies:
expo: ^52 || ^53 || ^54
react: '*'
react-native: '*'
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.8':
'@atproto/api@0.20.9':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10446,6 +10446,14 @@ snapshots:
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-responsive: 10.0.1(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon@1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@bsky.app/expo-guess-language@0.2.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -11449,14 +11457,6 @@ snapshots:
dependencies:
moo: 0.5.3
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
xcode: 3.0.1
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
+1 -1
View File
@@ -804,7 +804,7 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// Chat invite URLs (`/chat/: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
+7 -1
View File
@@ -9,7 +9,12 @@ import {
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {themes} from '#/alf/themes'
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
import {
contrastRatio,
darken,
lighten,
rgbToHex,
} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -26,6 +31,7 @@ export const utils = {
rgbToHex,
lighten,
darken,
contrastRatio,
}
export type Alf = {
+37 -1
View File
@@ -1,4 +1,10 @@
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
import {
contrastRatio,
darken,
hexToRgb,
lighten,
rgbToHex,
} from './colorGeneration'
describe('hexToRgb', () => {
it('parses 6-digit hex', () => {
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz')
})
})
describe('contrastRatio', () => {
it('returns 21 for black on white', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
})
it('returns 1 for identical colors', () => {
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
})
it('is symmetric regardless of argument order', () => {
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
contrastRatio('#fedcba', '#123456')!,
5,
)
})
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
})
it('returns null for invalid hex input', () => {
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
})
})
+42
View File
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}`
}
/**
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
* either argument is not a valid hex color.
*
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
*/
export function contrastRatio(hexA: string, hexB: string): number | null {
const rgbA = hexToRgb(hexA)
const rgbB = hexToRgb(hexB)
if (!rgbA || !rgbB) return null
const luminanceA = relativeLuminance(rgbA)
const luminanceB = relativeLuminance(rgbB)
const lighter = Math.max(luminanceA, luminanceB)
const darker = Math.min(luminanceA, luminanceB)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
* to 1 (white).
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance({
r,
g,
b,
}: {
r: number
g: number
b: number
}): number {
const toLinear = (channel: number) => {
const normalized = channel / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
}
function rgbToHsl(
r: number,
g: number,
+19
View File
@@ -0,0 +1,19 @@
import {useEffect, useState} from 'react'
import {Dimensions} from 'react-native'
/**
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
* whenever the screen size changes
*/
export function useNativeFontScale() {
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
useEffect(() => {
const sub = Dimensions.addEventListener('change', evt => {
setFontScale(evt.window.fontScale)
})
return () => sub.remove()
}, [])
return fontScale
}
+13 -15
View File
@@ -2,8 +2,6 @@ import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
import {Features, features} from '#/analytics/features'
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
/**
* Tracks passive analytics like app foreground/background time.
@@ -27,19 +25,19 @@ export function PassiveAnalytics() {
})
}
if (IS_DEV || IS_TESTFLIGHT) {
const feats = Object.values(Features).reduce(
(acc, feat) => {
acc[feat] = features.evalFeature(feat)
return acc
},
{} as Record<Features, any>,
)
ax.logger.info('FEATURES', {
features: feats,
definitions: features.getFeatures(),
})
}
// if (IS_DEV || IS_TESTFLIGHT) {
// const feats = Object.values(Features).reduce(
// (acc, feat) => {
// acc[feat] = features.evalFeature(feat)
// return acc
// },
// {} as Record<Features, any>,
// )
// ax.logger.info('FEATURES', {
// features: feats,
// definitions: features.getFeatures(),
// })
// }
})
return () => sub.remove()
}, [ax])
-4
View File
@@ -67,10 +67,6 @@ export class MetricsClient<M extends Record<string, any>> {
}
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
logger.debug(`sendBatch: ${events.length}`, {
isRetry,
})
try {
const body = JSON.stringify({events})
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
+25 -6
View File
@@ -1175,18 +1175,37 @@ export type Events = {
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Gallery carousel events
'post:gallery:swipe': {
// 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': {
fromImage: number
toImage: number
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:gallery:openLightbox': {
'post:photoEmbed:lightboxSwipe': {
layout: 'single' | 'grid' | 'carousel'
fromImage: number
totalImages: number
}
'post:gallery:impression': {
toImage: number
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[]
profiles: (bsky.profile.AnyProfileView | undefined)[]
/**
* 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?.did != null && p.did !== currentAccount?.did)
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
return profiles.map(p => {
return moderateProfile(p, moderationOpts)
return p && moderateProfile(p, moderationOpts)
})
}, [profiles, moderationOpts])
+28 -3
View File
@@ -45,7 +45,7 @@ export type ButtonColor =
| 'negative'
| 'primary_subtle'
| 'negative_subtle'
export type ButtonSize = 'tiny' | 'small' | 'large'
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
export type VariantProps = {
/**
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
(
{
children,
variant,
variant: variantProp,
color,
size,
shape = 'default',
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
* If a `color` is set, then we want to use the existing codepaths for
* "solid" buttons. This is to maintain backwards compatibility.
*/
if (!variant && color) {
let variant: VariantProps['variant'] = variantProp
if (!variantProp && color) {
variant = 'solid'
}
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
paddingHorizontal: 24,
gap: 6,
})
} else if (size === 'medium') {
baseStyles.push(a.rounded_full, {
paddingVertical: 9,
paddingHorizontal: 28,
gap: 5,
})
} else if (size === 'small') {
baseStyles.push(a.rounded_full, {
paddingVertical: 8,
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
borderRadius: 10,
gap: 3,
})
} else if (size === 'medium') {
baseStyles.push({
paddingVertical: 9,
paddingHorizontal: 16,
borderRadius: 8,
gap: 3,
})
} else if (size === 'small') {
baseStyles.push({
paddingVertical: 8,
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
} else {
baseStyles.push({height: 44, width: 44})
}
} else if (size === 'medium') {
if (shape === 'round') {
baseStyles.push({height: 33, width: 33})
} else {
baseStyles.push({height: 33, width: 33})
}
} else if (size === 'small') {
if (shape === 'round') {
baseStyles.push({height: 33, width: 33})
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
if (size === 'large') {
baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'medium') {
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') {
@@ -799,6 +821,7 @@ export function ButtonIcon({
size ??
(({
large: 'md',
medium: 'sm',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
@@ -828,6 +851,7 @@ export function ButtonIcon({
*/
const iconContainerSize = {
large: 20,
medium: 17,
small: 17,
tiny: 15,
}[buttonSize || 'small']
@@ -841,6 +865,7 @@ export function ButtonIcon({
if (buttonShape === 'default') {
iconNegativeMargin = {
large: -2,
medium: -2,
small: -2,
tiny: -1,
}[buttonSize || 'small']
+1
View File
@@ -499,6 +499,7 @@ function TriggerClone({
accessibilityLabel={label}
accessibilityHint={_(msg`The subject of the context menu`)}
accessibilityIgnoresInvertColors={false}
cachePolicy="none"
/>
</Animated.View>
)
+8 -13
View File
@@ -1,12 +1,10 @@
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} 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({
@@ -15,22 +13,20 @@ export function Error({
onRetry,
onGoBack,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
<CenteredView
<Layout.Center
style={[
a.h_full_vh,
a.align_center,
@@ -38,8 +34,7 @@ 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
@@ -58,7 +53,7 @@ export function Error({
<Button
variant="solid"
color="primary"
label={_(msg`Press to retry`)}
label={l`Press to retry`}
onPress={onRetry}
size="large">
<ButtonText>
@@ -70,7 +65,7 @@ export function Error({
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
label={l`Return to previous page`}
onPress={goBack}
size="large">
<ButtonText>
@@ -79,6 +74,6 @@ export function Error({
</Button>
)}
</View>
</CenteredView>
</Layout.Center>
)
}
+18 -6
View File
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
import {Image} from 'expo-image'
import {Trans, useLingui} from '@lingui/react/macro'
import {FocusGuards, FocusScope} from 'radix-ui/internal'
@@ -226,17 +226,21 @@ function LightboxGallery({
)}
</View>
{img.alt ? (
<View
<ScrollView
// Cap the overlay height so long alt text scrolls within the overlay
// instead of growing past the top of the screen and pushing the image
// out of view. Only scrollable once expanded.
style={[
a.px_4xl,
a.py_2xl,
styles.altScroll,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim,
]}>
]}
scrollEnabled={isAltExpanded}
contentContainerStyle={[a.px_4xl, a.py_2xl]}>
<Pressable
accessibilityLabel={l`Expand alt text`}
accessibilityHint={l`If alt text is long, toggles alt text expanded state`}
@@ -250,7 +254,7 @@ function LightboxGallery({
{img.alt}
</Text>
</Pressable>
</View>
</ScrollView>
) : null}
{imgs.length > 1 && (
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
@@ -449,6 +453,14 @@ const styles = StyleSheet.create({
padding: 16,
boxSizing: 'border-box',
},
altScroll: {
// Size to content like the View it replaced, rather than filling the
// column via ScrollView's default flexGrow.
flexGrow: 0,
flexShrink: 0,
// @ts-ignore web-only -sfn
maxHeight: '50vh',
},
menuBtn: {
top: 20,
left: 20,
+11 -1
View File
@@ -1,6 +1,9 @@
import {useRef} from 'react'
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro'
@@ -17,10 +20,16 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {t: l} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {height: screenHeight} = useSafeAreaFrame()
const isMomentumScrolling = useRef(false)
if (!altText) return null
// Cap the overlay height so long alt text - or text enlarged by the OS via
// Dynamic Type / font scaling - scrolls within the overlay instead of growing
// past the top of the screen. Leaves the upper half clear for the header.
const maxHeight = screenHeight / 2
return (
<View
style={[
@@ -46,6 +55,7 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
}),
]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
+27 -16
View File
@@ -1,6 +1,5 @@
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
import {BlurView} from 'expo-blur'
type Props = {
count: number
@@ -14,26 +13,38 @@ const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
<View style={styles.root}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</BlurView>
</View>
)
}
const styles = StyleSheet.create({
row: {
root: {
borderRadius: 999,
overflow: 'hidden',
},
inner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
activeDot: {
width: ACTIVE,
@@ -0,0 +1,62 @@
import {StyleSheet, View} from 'react-native'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={styles.root}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
root: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: GAP,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
@@ -246,6 +246,7 @@ const ImageItem = ({
}
}
cachePolicy="memory"
useAppleWebpCodec
/>
</Animated.View>
</Animated.View>
+22 -2
View File
@@ -32,12 +32,14 @@ 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'
@@ -136,6 +138,9 @@ export default function ImageViewRoot({
'worklet'
thumbRects.set({})
})()
requestIdleCallback(() => {
void Image.clearMemoryCache()
})
}, [thumbRects])
useAnimatedReaction(
@@ -224,7 +229,8 @@ function ImageView({
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
const {images, index: initialImageIndex} = lightbox
const {images, index: initialImageIndex, metricsContext} = lightbox
const ax = useAnalytics()
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -373,7 +379,21 @@ function ImageView({
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
setImageIndex(e.nativeEvent.position)
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
})
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
+10
View File
@@ -11,10 +11,20 @@ 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,7 +185,6 @@ let ListMaybePlaceholder = ({
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
@@ -226,7 +225,6 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)
}
+38 -3
View File
@@ -1,6 +1,10 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {
AppBskyEmbedGallery,
type AppBskyEmbedImages,
type AppBskyFeedDefs,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
@@ -47,6 +51,34 @@ export function Embed({
)}
</Outer>
)
} else if (e.type === 'gallery') {
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
// a 10-image gallery doesn't blow out the row width. Single pass instead
// of filter().slice().map() so we stop at 4 viewable items rather than
// walking every item in a 10-image gallery.
const tiles: React.ReactNode[] = []
for (const item of e.view.items) {
if (tiles.length >= 4) break
if (!AppBskyEmbedGallery.isViewImage(item)) continue
if (peekable) {
const image: AppBskyEmbedImages.ViewImage = {
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
} else {
tiles.push(
<ImageItem
key={item.thumbnail}
thumbnail={item.thumbnail}
alt={item.alt}
/>,
)
}
}
return <Outer style={style}>{tiles}</Outer>
} else if (e.type === 'link') {
if (!e.view.external.thumb) return null
if (!isGifEmbed(e.view.external.uri)) return null
@@ -95,10 +127,12 @@ export function ImageItem({
thumbnail,
alt,
children,
maxWidth = 100,
}: {
thumbnail?: string
alt?: string
children?: React.ReactNode
maxWidth?: number
}) {
const t = useTheme()
@@ -109,7 +143,7 @@ export function ImageItem({
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
{maxWidth},
a.rounded_xs,
]}
accessibilityLabel={alt}
@@ -120,7 +154,7 @@ export function ImageItem({
}
return (
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
@@ -129,6 +163,7 @@ export function ImageItem({
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
<Trans>Follows you</Trans>
</Text>
</View>
)
@@ -0,0 +1,48 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {atoms as a} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
/**
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
* a `bsky.app/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 {useCallback, useMemo} from 'react'
import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -51,17 +51,19 @@ export const ExternalEmbed = ({
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
const onPress = useCallback(() => {
const onPress = () => {
playHaptic('Light')
onOpen?.()
}, [playHaptic, onOpen])
}
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
}
}, [link.uri, playHaptic])
const onShareExternal = IS_NATIVE
? () => {
if (link.uri) {
playHaptic('Heavy')
void shareUrl(link.uri)
}
}
: undefined
if (
embedPlayerParams?.source === 'tenor' ||
@@ -108,6 +110,7 @@ export const ExternalEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -0,0 +1,99 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
+47 -5
View File
@@ -2,12 +2,16 @@ 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 {useLightboxControls} from '#/components/Lightbox/state'
import {
type LightboxMetricsContext,
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'
@@ -15,16 +19,43 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
const MAX_GRID_IMAGES = 4
export function ImageEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
embed: EmbedType<'images'> | EmbedType<'gallery'>
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const images: AppBskyEmbedImages.ViewImage[] =
embed.type === 'gallery'
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
: embed.view.images
const useExpandedLayout =
embed.type === 'gallery'
? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
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
// 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.
@@ -43,6 +74,14 @@ 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,
@@ -53,6 +92,7 @@ export function ImageEmbed({
type: 'image',
})),
index,
metricsContext,
})
}
const onPressIn = (_: number) => {
@@ -109,7 +149,7 @@ export function ImageEmbed({
)
}
if (galleryEnabled) {
if (useExpandedLayout) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
@@ -118,6 +158,7 @@ export function ImageEmbed({
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
metricsPostContext={postContext}
/>
</View>
)
@@ -130,6 +171,7 @@ export function ImageEmbed({
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/>
</View>
)
@@ -0,0 +1,116 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const JOIN_REQUEST_EMBED_HEIGHT = 140
/**
* The "join request" presentation of a chat invite, used as a post embed (in
* feeds and the post composer). Composes the headless `ChatInvite` primitive:
* pass either a `code` to fetch by, or an already-resolved `preview` as the
* initial data to avoid a loading flash.
*/
export function JoinRequestEmbed({
code,
preview,
style,
onOpen,
}: {
code?: string
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode = code ?? preview?.code
if (!resolvedCode) return null
return (
<ChatInvite.Root
code={resolvedCode}
initialPreview={preview}
hasFixedHeight>
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root>
)
}
/**
* The context-consuming presentation (loading / no-longer-available / card +
* join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
* to add an error fallback) can render it without nesting another Root.
*/
export function JoinRequestEmbedBody({
style,
onOpen,
}: {
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
if (loading) {
return (
<View
style={[
a.align_center,
a.justify_center,
a.p_lg,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
}
if (!preview) {
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.gap_xs,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
}
return (
<View
style={[
a.justify_between,
a.border,
a.rounded_lg,
a.p_lg,
a.gap_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<ChatInvite.Card size="large" />
<ChatInvite.JoinButton onPress={onOpen} />
</View>
)
}
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -80,13 +79,15 @@ export const StandardSiteEmbed = ({
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
const onLongPress = IS_NATIVE
? () => {
if (view.uri) {
playHaptic('Heavy')
void shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
: undefined
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
@@ -94,21 +95,17 @@ export const StandardSiteEmbed = ({
url: view.source?.uri || '',
})
}
const onLongPressPublication = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
useCallOnce(() => {
if (!preview) {
ax.metric('embed:standardSite:view', {url: view.uri})
}
})()
const onLongPressPublication = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
: undefined
if (isStandardPublication) {
return (
@@ -166,6 +163,7 @@ export const StandardSiteEmbed = ({
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
@@ -355,6 +353,7 @@ export function PublicationCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_md,
@@ -385,7 +384,7 @@ export function PublicationCard({
<View style={[a.pointer_events_none]}>
{view.description && (
<View style={[a.pt_sm]}>
<Text style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
<Text emoji style={[a.text_sm, a.leading_snug]} numberOfLines={3}>
{view.description}
</Text>
</View>
@@ -425,6 +424,26 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication`
/*
* The custom site theme paints the button background with `accent` and the
* text with `accentForeground`. Only honor it when that pairing clears WCAG
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
* Otherwise we fall through to the default `secondary_inverted` styling,
* which is guaranteed to be legible.
*/
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
let useCustomTheme = false
if (accentRGB && accentForegroundRGB) {
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const ratio = utils.contrastRatio(accent, accentForeground)
useCustomTheme = ratio !== null && ratio >= 4.5
}
if (!view.source) return null
const publicationTitle = view.source.title
@@ -450,52 +469,60 @@ export function SubscribeButton({
}
}
const onLongPress = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
const onLongPress = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
}
}
}
: undefined
const button = (
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
)
if (!useCustomTheme) {
return button
}
return (
<StandardSiteThemeProvider view={view}>
<Link
shouldProxy
to={view.source.uri}
label={label}
size="small"
color="secondary_inverted"
style={[
style,
a.gap_sm,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={onPress}
onLongPress={onLongPress}>
{highlightedPublisher ? (
<>
<View style={[a.flex_row, a.align_center, {gap: 7}]}>
<ButtonIcon icon={highlightedPublisher.Icon} size="md" />
</View>
<ButtonText>{cta}</ButtonText>
</>
) : (
<>
<ButtonText>{cta}</ButtonText>
<ButtonIcon icon={ArrowTopRightIcon} />
</>
)}
</Link>
</StandardSiteThemeProvider>
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
)
}
@@ -616,6 +643,7 @@ export function PublicationFooter({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
numberOfLines={1}
style={[
a.text_sm,
+23 -1
View File
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {makeProfileLink} from '#/lib/routes/links'
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -33,6 +34,7 @@ import {
type EmbedType,
parseEmbed,
} from '#/types/bsky/post'
import {ChatInviteEmbed} from './ChatInviteEmbed'
import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
@@ -52,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
switch (embed.type) {
case 'images':
case 'gallery':
case 'link':
case 'video': {
return <MediaEmbed embed={embed} {...rest} />
@@ -87,7 +90,8 @@ function MediaEmbed({
embed: TEmbed
}) {
switch (embed.type) {
case 'images': {
case 'images':
case 'gallery': {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -110,6 +114,21 @@ function MediaEmbed({
</ContentHider>
)
}
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
if (chatInviteCode) {
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<ChatInviteEmbed
code={chatInviteCode}
link={embed.view.external}
onOpen={rest.onOpen}
style={rest.style}
/>
</ContentHider>
)
}
return (
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
@@ -326,6 +345,9 @@ 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,6 +15,13 @@ 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 & {
+11 -6
View File
@@ -1,7 +1,8 @@
import {useWindowDimensions, View} from 'react-native'
import {View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -31,14 +32,16 @@ export function ProfileBadges({
interactive = false,
size,
style,
allowFontScaling = true,
}: ViewStyleProp & {
profile: bsky.profile.AnyProfileView
interactive?: boolean
size: Size
allowFontScaling?: boolean
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
const nativeScaleMultiplier = useNativeFontScale()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
@@ -48,10 +51,12 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm'
const verificationIconWidth =
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth =
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const scaleMultiplier = allowFontScaling
? nativeScaleMultiplier * alfScaleMultiplier
: 1
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
return (
<View
+3 -1
View File
@@ -23,6 +23,7 @@ export function Text({
title,
dataSet,
numberOfLines,
allowFontScaling = true,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
@@ -36,7 +37,7 @@ export function Text({
style,
],
{
fontScale: fonts.scaleMultiplier,
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
fontFamily: fonts.family,
flags,
},
@@ -57,6 +58,7 @@ export function Text({
numberOfLines,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
allowFontScaling,
...rest,
}
@@ -60,6 +60,7 @@ export function FindContactsBannerNUX() {
a.self_end,
a.mt_sm,
]}
useAppleWebpCodec
/>
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text
@@ -27,6 +27,7 @@ export function ContactsHeroImage() {
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
useAppleWebpCodec
/>
</View>
)
@@ -667,7 +667,6 @@ function SearchInput({
/>
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={l`Search`}
value={value}
@@ -113,6 +113,7 @@ export function ActivitySubscriptionsNUX() {
alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -124,6 +124,7 @@ export function BookmarksAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -101,6 +101,7 @@ export function DraftsAnnouncement() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
@@ -78,6 +78,7 @@ export function FindContactsAnnouncement() {
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
useAppleWebpCodec
/>
</View>
</View>
@@ -85,6 +85,7 @@ export function InitialVerificationAnnouncement() {
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
useAppleWebpCodec
/>
</View>
@@ -119,6 +120,7 @@ export function InitialVerificationAnnouncement() {
alt={_(
msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`,
)}
useAppleWebpCodec
/>
</View>
@@ -150,6 +150,7 @@ export function LiveNowBetaDialog() {
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
useAppleWebpCodec
/>
</View>
</View>
+11
View File
@@ -11,6 +11,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
@@ -141,6 +142,15 @@ export function AddMembersFlow({
[memberListData],
)
const {data: chatStatus} = useChatActorStatusQuery()
const groupMemberLimit = chatStatus?.groupMemberLimit
// The existing members (including the viewer) already occupy slots, so the
// number of people that can still be added is whatever's left.
const remainingSlots =
groupMemberLimit !== undefined
? Math.max(0, groupMemberLimit - memberListData.length)
: undefined
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
groupChatDids: [],
groupChatProfiles: [],
@@ -471,6 +481,7 @@ export function AddMembersFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={remainingSlots}
label={l`Add group chat members`}
style={web([a.contents])}>
<Dialog.InnerFlatList
+103
View File
@@ -0,0 +1,103 @@
import {View} from 'react-native'
import {Plural, Trans} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {SimpleInlineLinkText} from '#/components/Link'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useChatInvite} from './Context'
/**
* Presentational preview of a chat invite: member avatars, group name, member
* count, and owner. Reads the preview from `ChatInvite.Root` context. Renders
* nothing if there's no preview (use a fallback alongside it for that case).
*/
export function Card({size}: {size: 'large' | 'small'}) {
const t = useTheme()
const {preview, hasFixedHeight} = useChatInvite()
if (!preview) return null
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
const avatarProfiles = preview.convo?.members ?? [preview.owner]
return (
<View style={[a.flex_row, a.gap_md, a.align_center]}>
<AvatarBubbles size={56} self profiles={avatarProfiles} />
<View style={[a.flex_1, size === 'large' ? a.gap_2xs : a.gap_xs]}>
<Text
emoji
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
{preview.name}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[
a.text_2xs,
a.leading_tight,
a.font_medium,
t.atoms.text_contrast_high,
]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '}
<Plural
value={preview.memberCount}
one="member"
other="members"
/>
</Trans>
</Text>
</View>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
size === 'large' && a.mt_2xs,
]}>
<Text
emoji
style={[a.flex_shrink, a.text_sm, a.font_medium]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
By{' '}
<SimpleInlineLinkText
to={makeProfileLink(preview.owner)}
label={ownerDisplayName}
style={[a.font_medium, t.atoms.text]}>
{ownerDisplayName}
</SimpleInlineLinkText>
</Trans>
</Text>
<ProfileBadges
profile={preview.owner}
size="sm"
allowFontScaling={!hasFixedHeight}
/>
<Text
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
{ownerHandle}
</Text>
</View>
</View>
</View>
)
}
+49
View File
@@ -0,0 +1,49 @@
import {createContext, useContext} from 'react'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {type ButtonColor} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
/**
* The derived state of the join/open action for a chat invite, computed once in
* `Root` and consumed by `JoinButton` (or any custom action UI).
*/
export type ChatInviteAction = {
label: string
accessibilityHint: string
icon: React.ComponentType<SVGIconProps>
color: ButtonColor
/**
* Whether the action can be performed. False when the link is disabled, the
* chat is full, or the viewer doesn't meet the join rule.
*/
disabled: boolean
onPress: () => void
side: 'left' | 'right'
}
export type ChatInviteContextValue = {
code: string
loading: boolean
error: boolean
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
/**
* The derived action descriptor. Undefined while loading or when there's no
* preview to act on.
*/
action: ChatInviteAction | undefined
/** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
hasFixedHeight: boolean
}
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
export function useChatInvite(): ChatInviteContextValue {
const ctx = useContext(ChatInviteContext)
if (!ctx) {
throw new Error('useChatInvite must be used within a ChatInvite.Root')
}
return ctx
}
export const ChatInviteProvider = ChatInviteContext.Provider
@@ -0,0 +1,42 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useChatInvite} from './Context'
/**
* The join/open action button for a chat invite. Reads the derived action from
* `ChatInvite.Root` context. Pass `onPress` to intercept (e.g. to close a
* surface before navigating); it runs before the default action. Renders
* nothing while loading or when there's no preview to act on.
*/
export function JoinButton({
onPress,
style,
}: {
onPress?: () => void
style?: StyleProp<ViewStyle>
}) {
const {action, hasFixedHeight} = useChatInvite()
if (!action) return null
return (
<Button
testID="joinButton"
onPress={() => {
onPress?.()
action.onPress()
}}
label={action.label}
accessibilityHint={action.accessibilityHint}
size="medium"
color={action.color}
disabled={action.disabled}
style={[a.w_full, style]}>
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
<ButtonText allowFontScaling={!hasFixedHeight}>{action.label}</ButtonText>
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
</Button>
)
}
+146
View File
@@ -0,0 +1,146 @@
import {setStringAsync} from 'expo-clipboard'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {type ButtonColor} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink'
import {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
@@ -0,0 +1,8 @@
export {Card} from './Card'
export {
type ChatInviteAction,
type ChatInviteContextValue,
useChatInvite,
} from './Context'
export {JoinButton} from './JoinButton'
export {Root} from './Root'
+57 -65
View File
@@ -1,6 +1,6 @@
import {memo, useCallback} from 'react'
import {Keyboard, View} from 'react-native'
import {ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
import {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,13 +16,18 @@ 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 {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
import {
type ConvoWithDetails,
getConvoReportSubject,
} from '#/components/dms/util'
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'
@@ -39,7 +44,6 @@ 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,
@@ -49,10 +53,9 @@ let ConvoMenu = ({
showMarkAsRead,
hideTrigger,
blockInfo,
latestReportableMessage,
style,
}: {
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
profile: Shadow<bsky.profile.AnyProfileView>
control?: Menu.MenuControlProps
currentScreen: 'list' | 'conversation'
@@ -62,20 +65,21 @@ 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 blockOrDeleteControl = Prompt.usePromptControl()
const deleteControl = Prompt.usePromptControl()
const afterReportControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo
const reportSubject = getConvoReportSubject(convo, currentAccount?.did)
return (
<>
<Menu.Root control={control}>
@@ -108,6 +112,7 @@ let ConvoMenu = ({
showMarkAsRead={showMarkAsRead}
blockInfo={blockInfo}
convo={convo}
canReport={!!reportSubject}
leaveConvoControl={leaveConvoControl}
reportControl={reportControl}
blockedByListControl={blockedByListControl}
@@ -116,54 +121,37 @@ let ConvoMenu = ({
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
convoId={convo.view.id}
currentScreen={currentScreen}
/>
{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,
}}
/>
</>
{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,
}}
/>
) : (
<>
<ReportConversationDialog
control={reportControl}
convoId={convo.id}
did={profile.did}
onAfterSubmit={deleteControl.open}
/>
<AfterReportConversationDialog
control={deleteControl}
currentScreen={currentScreen}
params={{
convoId: convo.id,
did: profile.did,
}}
/>
</>
<AfterReportDialog
control={afterReportControl}
currentScreen={currentScreen}
params={{
convoId: convo.view.id,
did: profile.did,
}}
/>
)}
<BlockedByListDialog
control={blockedByListControl}
@@ -177,14 +165,16 @@ ConvoMenu = memo(ConvoMenu)
function MenuContent({
convo: initialConvo,
profile,
canReport,
showMarkAsRead,
blockInfo,
leaveConvoControl,
reportControl,
blockedByListControl,
}: {
convo: ChatBskyConvoDefs.ConvoView
convo: ConvoWithDetails
profile: Shadow<bsky.profile.AnyProfileView>
canReport: boolean
showMarkAsRead?: boolean
blockInfo: {
listBlocks: ModerationCause[]
@@ -201,9 +191,9 @@ function MenuContent({
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const isDeletedAccount = profile.handle === 'missing.invalid'
const isGroupConvo = ChatBskyConvoDefs.isGroupConvo(initialConvo.kind)
const isGroupConvo = initialConvo.kind === 'group'
const convoId = initialConvo.id
const convoId = initialConvo.view.id
const {data: convo} = useConvoQuery({convoId})
const onNavigateToProfile = useCallback(() => {
@@ -299,15 +289,17 @@ function MenuContent({
</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>
{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.Group>
<Menu.Divider />
<Menu.Group>
+36 -3
View File
@@ -8,10 +8,12 @@ import {
} from 'react'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
@@ -212,6 +214,7 @@ export function InitiateChatFlow({
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
const [searchText, setSearchText] = useState('')
@@ -458,6 +461,11 @@ export function InitiateChatFlow({
}
}, [])
const groupNameTooLong = isOverMaxGraphemeCount({
text: groupName,
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
})
let buttonLabel = l`Continue to group name`
let buttonText = l`Next`
let handleButtonPress = handlePressNext
@@ -470,7 +478,7 @@ export function InitiateChatFlow({
buttonText = l`Create`
handleButtonPress = handlePressConfirm
showButton = true
isButtonDisabled = groupName === ''
isButtonDisabled = groupName === '' || groupNameTooLong
break
}
@@ -564,7 +572,7 @@ export function InitiateChatFlow({
{chatState === ChatState.GROUP_NAME ? (
<View
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
<TextField.Root>
<TextField.Root isInvalid={groupNameTooLong}>
<TextField.Input
label={l`Group name`}
value={groupName}
@@ -573,6 +581,7 @@ export function InitiateChatFlow({
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="text"
clearButtonMode="while-editing"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
@@ -582,6 +591,23 @@ 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
@@ -622,6 +648,8 @@ export function InitiateChatFlow({
handleButtonPress,
buttonText,
groupName,
groupNameTooLong,
t.palette.negative_400,
searchText,
control,
showChatProfileTabs,
@@ -664,6 +692,11 @@ export function InitiateChatFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={
// groupMemberLimit counts the creator, who is added implicitly, so
// reserve one slot for them
groupMemberLimit ? groupMemberLimit - 1 : undefined
}
label={
chatState === ChatState.NEW_GROUP_CHAT
? l`Select group chat members`
+15 -5
View File
@@ -1,7 +1,9 @@
import {ChatBskyConvoLeaveConvo} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {type DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
@@ -30,10 +32,18 @@ export function LeaveConvoPrompt({
)
}
},
onError: () => {
Toast.show(l`Could not leave chat`, {
type: 'error',
})
onError: error => {
let errorMessage = l`Could not leave chat`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (error instanceof ChatBskyConvoLeaveConvo.InvalidConvoError) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyConvoLeaveConvo.OwnerCannotLeaveError
) {
errorMessage = l`Owner must lock the group before leaving.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
@@ -43,7 +53,7 @@ export function LeaveConvoPrompt({
title={l`Leave conversation`}
description={
hasMessages
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participants.`
: l`Are you sure you want to leave this conversation?`
}
confirmButtonCta={l`Leave`}
+15 -2
View File
@@ -20,6 +20,7 @@ import {
AppBskyEmbedRecord,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
RichText as RichTextAPI,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
@@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
@@ -185,8 +187,10 @@ let MessageItem = ({
const rt = new RichTextAPI({text: message.text, facets: message.facets})
const hasEmbedAndText =
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
const hasEmbed =
AppBskyEmbedRecord.isView(message.embed) ||
ChatBskyEmbedJoinLink.isView(message.embed)
const hasEmbedAndText = hasEmbed && rt.text.length > 0
const targetBottomRadius = squaredBottomCorner
? SQUARED_BORDER_RADIUS
@@ -427,6 +431,15 @@ let MessageItem = ({
squaredTopCorner={squaredTopCorner}
/>
)}
{ChatBskyEmbedJoinLink.isView(message.embed) && (
<MessageItemInviteEmbed
embed={message.embed}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
squaredTopCorner={squaredTopCorner}
/>
)}
{rt.text.length > 0 && (
<Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
@@ -0,0 +1,88 @@
import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, native, useTheme, web} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {MessageContextProvider} from './MessageContext'
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
let MessageItemInviteEmbed = ({
embed,
isFromSelf,
isGroupChat,
squaredTopCorner,
squaredBottomCorner,
}: {
embed: $Typed<ChatBskyEmbedJoinLink.View>
isFromSelf: boolean
isGroupChat: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
const convo = useConvoActive()
return (
<MessageContextProvider>
<View
style={[
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
}),
web({
width: '100%',
minWidth: 280,
maxWidth: 360,
}),
]}>
<View
style={[
a.p_md,
a.gap_md,
a.overflow_hidden,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderBottomLeftRadius: BORDER_RADIUS,
borderTopLeftRadius: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderBottomRightRadius: BORDER_RADIUS,
borderTopRightRadius: BORDER_RADIUS,
},
]}>
<ChatInvite.Root
code={embed.joinLinkPreview.code}
initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}
hasFixedHeight={false}>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
</ChatInvite.Root>
</View>
</View>
</MessageContextProvider>
)
}
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
export {MessageItemInviteEmbed}
+18 -2
View File
@@ -125,6 +125,22 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
[openDeleteMessage, openReportMessage, openReactions],
)
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
// live message out of the convo items so optimistic reaction changes (e.g.
// "Tap to remove") are reflected in the dialog without closing it first.
const reactionsMessage = useMemo(() => {
if (!reactionsTarget) return null
for (const item of convo.items) {
if (
(item.type === 'message' || item.type === 'pending-message') &&
item.message.id === reactionsTarget.id
) {
return item.message
}
}
return reactionsTarget
}, [convo.items, reactionsTarget])
const reportSubject = reportTarget
? ({
view: 'message',
@@ -153,11 +169,11 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsTarget && (
{reactionsMessage && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsTarget}
message={reactionsMessage}
onClose={() => setReactionsTarget(null)}
/>
)}
+2 -15
View File
@@ -1,10 +1,6 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {
ChatBskyConvoDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -12,7 +8,6 @@ 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'
@@ -88,7 +83,6 @@ function ProfileHeaderReady({
}) {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const profile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts)
@@ -110,12 +104,6 @@ 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={
@@ -151,11 +139,10 @@ function ProfileHeaderReady({
}
settings={
<ConvoMenu
convo={convo.view}
convo={convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
}
/>
@@ -34,32 +34,40 @@ export function GroupChatProfileCard({
name={profile.did}
label={displayName}
style={[a.flex_1, a.py_sm, a.px_lg]}>
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
{({disabled, selected}) => (
<>
<View
style={[
a.flex_grow,
!enabled || (disabled && !selected) ? {opacity: 0.5} : null,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
{enabled ? <Toggle.Checkbox /> : null}
</>
)}
</Toggle.Item>
)
}
+55
View File
@@ -10,6 +10,7 @@ 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
@@ -47,6 +48,22 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
}
}
/**
* Resolves the effective `allowGroupInvites` value for a chat declaration.
* When unset, group invites follow the general DM preference
* (`allowIncoming`), which itself defaults to `following`. This mirrors the
* `undefined` fallthrough in canBeAddedToGroup, and is the single source of
* truth for both displaying and persisting the setting.
*/
export function resolveAllowGroupInvites(
chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
): 'all' | 'none' | 'following' {
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
| 'all'
| 'none'
| 'following'
}
export function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
@@ -224,3 +241,41 @@ 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,6 +142,7 @@ export function AutoSizedImage({
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder />
+53 -7
View File
@@ -40,7 +40,7 @@ import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {IS_ANDROID, IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
@@ -55,6 +55,13 @@ 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<{
@@ -99,6 +106,7 @@ export function Gallery({
onPressIn,
viewContext,
isWithinQuote,
metricsPostContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
@@ -169,13 +177,17 @@ export function Gallery({
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
ax.metric('post:gallery:swipe', {
if (!metricsPostContext) return
ax.metric('post:photoEmbed:carouselSwipe', {
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],
[ax, images.length, metricsPostContext],
)
const setCurrentIndex = (index: number) => {
@@ -264,6 +276,9 @@ export function Gallery({
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
// Disable Android's stretch overscroll, which can leave the carousel
// settled just off the left edge instead of aligned to x = 0
overScrollMode={IS_ANDROID ? 'never' : 'auto'}
showsHorizontalScrollIndicator={false}
directionalLockEnabled
nestedScrollEnabled
@@ -274,10 +289,6 @@ 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++) {
@@ -340,6 +351,11 @@ export function Gallery({
marginLeft: -insetLeft,
width,
},
// Prevent horizontal trackpad/wheel swipes from triggering the
// browser's back/forward overscroll-navigation gesture. Handles
// Chrome and Firefox; Safari is handled via the wheel listener in
// usePointerHandlers.web.ts since it ignores overscroll-behavior.
web({overscrollBehaviorX: 'contain'}),
]}
contentContainerStyle={{
gap: ITEM_GAP,
@@ -480,8 +496,38 @@ function GalleryImage({
height: e.source.height,
})
}}
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
top: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
@@ -1,4 +1,5 @@
import {
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
@@ -27,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
@@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
return
}
// The gate only controls whether legacy image embeds opt into the new
// expanded gallery layout. Gallery embeds always render expanded by item
// count, so their offset must apply regardless of the gate.
const isPostGalleryEmbedEnabled = features.isOn(
Features.PostGalleryEmbedEnable,
)
/*
* First check if we even have images
*/
@@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles(
embed,
AppBskyEmbedImages.isMain,
)
const isGalleryEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed,
AppBskyEmbedGallery.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
@@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles(
)
let hasImages = false
if (isImageEmbed) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isGalleryEmbed) {
// single (or empty) gallery - no offset needed
if (embed.items.length <= 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
@@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles(
AppBskyEmbedImages.isMain,
)
) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery
if (embed.media.images.length === 1) return
}
if (
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed.media,
AppBskyEmbedGallery.isMain,
)
) {
// single (or empty) gallery - no offset needed
if (embed.media.items.length <= 1) return
}
hasImages = true
}
if (!hasImages) return
@@ -4,6 +4,7 @@ import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
import {IS_WEB_SAFARI} from '#/env'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
@@ -246,12 +247,64 @@ export function usePointerHandlers({
}
}
/*
* Safari does not support `overscroll-behavior`, so a horizontal trackpad
* swipe over the carousel can trigger the browser's back/forward
* navigation gesture. We intercept predominantly-horizontal wheel events
* and apply the scroll ourselves, calling preventDefault to suppress the
* history-nav gesture. Vertical-dominant wheel events are left untouched so
* normal page scroll still works. Chrome/Firefox are covered by the
* `overscrollBehaviorX: 'contain'` style on the FlatList.
*
* Listener must be non-passive so preventDefault is honored.
*/
const onWheel = (e: WheelEvent) => {
if (!IS_WEB_SAFARI) return
// Only act on predominantly-horizontal scrolls. Vertical-dominant events
// are page scroll and must not be swallowed.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
e.preventDefault()
// Cancel any in-progress settle tween so manual scrolling feels direct.
if (stopTween) {
stopTween()
stopTween = null
}
if (overscrollX !== 0) clearOverscroll()
const maxScroll = el.scrollWidth - el.clientWidth
const next = Math.max(0, Math.min(el.scrollLeft + e.deltaX, maxScroll))
scrollTo(next)
// Keep the active index in sync so keyboard/lightbox stay correct, but
// only settle when it actually changes - onSettle moves focus, which we
// don't want to thrash on every wheel tick.
let accumulated = 0
let index = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (next < accumulated + w / 2) {
index = i
break
}
accumulated += w
if (i === imageCount - 1) index = i
}
if (index !== localIndex) {
localIndex = index
onSettle(index)
}
}
el.addEventListener('mousedown', onMouseDown)
el.addEventListener('wheel', onWheel, {passive: false})
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('wheel', onWheel)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
+15 -7
View File
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
export function ImageLayoutGrid({
style,
isWithinQuote: isWithinQuoteProp,
...props
}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints()
const gap =
const isWithinQuote =
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? gtMobile
? a.gap_xs
: a.gap_2xs
: a.gap_xs
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
return (
<View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} />
<ImageLayoutGridInner
{...props}
gap={gap}
isWithinQuote={isWithinQuote}
/>
</View>
</View>
)
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
gap: {gap: number}
}
@@ -29,6 +29,7 @@ interface Props {
onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
@@ -42,6 +43,7 @@ export function GalleryItem({
onPressIn,
onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle,
containerRefs,
thumbDimsRef,
@@ -52,6 +54,7 @@ export function GalleryItem({
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
@@ -106,6 +109,7 @@ export function GalleryItem({
}
}}
loading="lazy"
useAppleWebpCodec
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession,
staleTime: 0,
})
const {mutate: joinGroupChat, isPending: isJoinPending} =
@@ -133,7 +134,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
) {
errorMessage = l`The member limit has been reached.`
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
errorMessage = l`You have been removed from this group.`
errorMessage = l`You have been previously removed from this group and cant join it using this link.`
}
Toast.show(errorMessage)
},
@@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
<View
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
<Text
emoji
style={[
a.mb_2xs,
a.text_center,
@@ -402,7 +404,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
color="primary"
disabled={!code}
style={[a.w_full]}>
<ButtonText>Open chat</ButtonText>
<ButtonText>
<Trans>Open chat</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
) : (
@@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
}
accessibilityHint={
joinLinkPreview.requireApproval
? l`Request access to join this group chat`
: l`Join this group chat`
? l`Tap to request access to join this group chat`
: l`Tap to join this group chat immediately`
}
size="large"
color={buttonColor}
@@ -87,7 +87,10 @@ export function parseReportSubject(
reply: !!record.reply,
image:
embed.type === 'images' ||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
embed.type === 'gallery' ||
(embed.type === 'post_with_media' &&
(embed.media.type === 'images' ||
embed.media.type === 'gallery')),
video:
embed.type === 'video' ||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
@@ -87,6 +87,7 @@ function Inner({
alt={_(
msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`,
)}
useAppleWebpCodec
/>
</View>
@@ -69,6 +69,7 @@ export function LiveEventFeedCardCompact({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
useAppleWebpCodec
/>
<LinearGradient
@@ -77,6 +77,7 @@ export function LiveEventFeedCardWide({
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
contentFit="cover"
placeholderContentFit="cover"
useAppleWebpCodec
/>
<LinearGradient
@@ -54,6 +54,7 @@ export function LinkPreview({
contentFit="cover"
onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)}
useAppleWebpCodec
/>
)}
{linkMeta && (!linkMeta.image || imageLoadError) && (
@@ -147,6 +147,7 @@ export function LiveStatus({
contentFit="cover"
style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
<LiveIndicator
size="large"
+52 -3
View File
@@ -1,6 +1,7 @@
import {
type $Typed,
type AppBskyEmbedExternal,
type AppBskyEmbedGallery,
type AppBskyEmbedImages,
type AppBskyEmbedRecord,
type AppBskyEmbedRecordWithMedia,
@@ -21,6 +22,7 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
@@ -177,7 +179,8 @@ export async function post(
writes: writes,
validate: true,
})
} catch (e: any) {
} catch (err) {
const e = err as Error
logger.error(`Failed to create post`, {
safeMessage: e.message,
})
@@ -252,6 +255,7 @@ async function resolveEmbed(
onStateChange: ((state: string) => void) | undefined,
): Promise<
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main>
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedRecord.Main>
@@ -311,6 +315,7 @@ async function resolveMedia(
): Promise<
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main>
| undefined
> {
@@ -323,7 +328,10 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
@@ -338,6 +346,34 @@ async function resolveMedia(
images,
}
}
if (embedDraft.media?.type === 'gallery') {
const imagesDraft = embedDraft.media.images
logger.debug(`Uploading images`, {
count: imagesDraft.length,
})
onStateChange?.(t`Uploading images...`)
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(
image,
IMAGE_SIZE_CONFIG_POSTS,
)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
$type: 'app.bsky.embed.gallery#image' as const,
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
}
}),
)
return {
$type: 'app.bsky.embed.gallery',
items,
}
}
if (
embedDraft.media?.type === 'video' &&
embedDraft.media.video.status === 'done'
@@ -427,6 +463,16 @@ async function resolveMedia(
},
}
}
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
return {
$type: 'app.bsky.embed.external',
external: {
uri: resolvedLink.uri,
title: resolvedLink.view.name,
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
},
}
}
}
return undefined
}
@@ -470,6 +516,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
}
// Returns a transformed version of the object for use in DAG-CBOR.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function prepareForHashing(v: any): any {
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
// the API client will convert this for you but we're hashing in the client,
@@ -492,9 +539,10 @@ function prepareForHashing(v: any): any {
// Walk through plain objects
if (isPlainObject(v)) {
const obj: any = {}
const obj: Record<string, unknown> = {}
let pure = true
for (const key in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let value = v[key]
// `value` is undefined
if (value === undefined) {
@@ -513,6 +561,7 @@ function prepareForHashing(v: any): any {
return v
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPlainObject(v: any): boolean {
if (typeof v !== 'object' || v === null) {
return false
+25 -5
View File
@@ -2,11 +2,12 @@ import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type BskyAgent,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {POST_IMG_MAX} from '#/lib/constants'
import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} 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'
@@ -16,6 +17,7 @@ import {
} from '#/lib/strings/starter-pack'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyCustomFeedUrl,
isBskyListUrl,
isBskyPostUrl,
@@ -71,12 +73,20 @@ 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() {
@@ -141,6 +151,19 @@ 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) {
@@ -261,10 +284,7 @@ export async function imageToThumb(
try {
const img = await downloadAndResize({
uri: imageUri,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
...IMAGE_SIZE_CONFIG_2K_1MB,
timeout: 15e3,
})
if (img) {
+10 -4
View File
@@ -67,6 +67,8 @@ 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
@@ -97,10 +99,14 @@ export const STAGING_FEEDS = [
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
]
export const POST_IMG_MAX = {
width: 2000,
height: 2000,
size: 1000000,
export const IMAGE_SIZE_CONFIG_POSTS = {
maxDimension: 4000,
maxSize: 2000000,
}
export const IMAGE_SIZE_CONFIG_2K_1MB = {
maxDimension: 2000,
maxSize: 1000000,
}
export const STAGING_LINK_META_PROXY =
+16 -39
View File
@@ -16,24 +16,21 @@ 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} from './util'
import {convertCdnPreset, getResizedDimensions} from './util'
export async function compressIfNeeded(
img: PickerImage,
maxSize: number = POST_IMG_MAX.size,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> {
if (img.size < maxSize) {
return img
}
const resizedImage = await doResize(normalizePath(img.path), {
width: img.width,
height: img.height,
mode: 'stretch',
maxDimension,
maxSize,
})
const finalImageMovedPath = await moveToPermanentPath(
@@ -49,9 +46,7 @@ export async function compressIfNeeded(
export interface DownloadAndResizeOpts {
uri: string
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxDimension: number
maxSize: number
timeout: number
}
@@ -67,7 +62,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)
try {
return await doResize(path, opts)
return await doResize(path, {
maxDimension: opts.maxDimension,
maxSize: opts.maxSize,
})
} finally {
void safeDeleteAsync(path)
}
@@ -188,9 +186,7 @@ export function getImageDim(path: string): Promise<Dimensions> {
// =
interface DoResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxDimension: number
maxSize: number
}
@@ -204,10 +200,13 @@ 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,
})
const newDimensions = getResizedDimensions(
{
width: imageRes.width,
height: imageRes.height,
},
opts.maxDimension,
)
let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive
@@ -388,28 +387,6 @@ 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.
+22 -17
View File
@@ -1,27 +1,28 @@
import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util'
import {
blobToDataUri,
convertCdnPreset,
getDataUriSize,
getResizedDimensions,
} from './util'
export async function compressIfNeeded(
img: PickerImage,
maxSize: number,
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
): Promise<PickerImage> {
if (img.size < maxSize) {
return img
}
return await doResize(img.path, {
width: img.width,
height: img.height,
mode: 'stretch',
maxDimension,
maxSize,
})
}
export interface DownloadAndResizeOpts {
uri: string
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxDimension: number
maxSize: number
timeout: number
}
@@ -34,7 +35,10 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
clearTimeout(to)
const dataUri = await blobToDataUri(resBody)
return await doResize(dataUri, opts)
return await doResize(dataUri, {
maxDimension: opts.maxDimension,
maxSize: opts.maxSize,
})
}
export async function shareImageModal(_opts: {uri: string}) {
@@ -70,9 +74,7 @@ export async function getImageDim(path: string): Promise<Dimensions> {
// =
interface DoResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxDimension: number
maxSize: number
}
@@ -80,6 +82,9 @@ 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
@@ -90,10 +95,10 @@ async function doResize(
(maxQualityPercentage + minQualityPercentage) / 2,
)
const tempDataUri = await createResizedImage(dataUri, {
width: opts.width,
height: opts.height,
width: newDimensions.width,
height: newDimensions.height,
quality: qualityPercentage / 100,
mode: opts.mode,
mode: 'contain',
})
if (getDataUriSize(tempDataUri) < opts.maxSize) {
@@ -111,8 +116,8 @@ async function doResize(
path: newDataUri,
mime: 'image/jpeg',
size: getDataUriSize(newDataUri),
width: opts.width,
height: opts.height,
width: newDimensions.width,
height: newDimensions.height,
}
}
+11 -7
View File
@@ -8,6 +8,7 @@ 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'
@@ -28,13 +29,16 @@ 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,
})
return await compressIfNeeded(
{
path: file,
mime: 'image/jpeg',
size: fileInfo.size,
width: 4288,
height: 2848,
},
IMAGE_SIZE_CONFIG_2K_1MB,
)
}
export async function openPicker(): Promise<PickerImage[]> {
+25
View File
@@ -2,6 +2,31 @@ 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 {
+4 -3
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import psl from 'psl'
import {parse} from 'psl'
import TLDs from 'tlds'
import {BSKY_SERVICE} from '#/lib/constants'
@@ -178,7 +178,8 @@ export function isBskyStarterPackUrl(url: string): boolean {
return false
}
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
// 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 function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
@@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean {
}
export function splitApexDomain(hostname: string): [string, string] {
const hostnamep = psl.parse(hostname)
const hostnamep = 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