Compare commits

...

69 Commits

Author SHA1 Message Date
vineyardbovines 419eaa9c83 Bump mediabunny to 1.49.0 in lockfile
Mediabunny shipped 1.49.0 since this PR was first committed; the
lockfile check ("No manual pnpm-lock.yaml edits") was failing because
a fresh `pnpm install --no-frozen-lockfile` would resolve to 1.49.0
within our `^1.25.3` range. Matches what CI expects from a fresh
install against main's lockfile.
2026-06-23 11:23:00 -04:00
vineyardbovines a9179fa296 Address review: extract shared compression constants, log empty output
- Move TARGET_BITRATE, MAX_DIMENSION, and the 25 MB skip threshold to a
  new src/lib/media/video/constants.ts so both compress.web.ts and the
  upcoming native engine use the same values. Codec lists stay local to
  compress.web.ts since they are mediabunny-specific.
- When mediabunny's BufferTarget returns a null buffer after a clean
  execute(), log via logger.error so Sentry captures the unexpected
  state. The outer catch still demotes the throw to a warn and falls
  back to the original blob, so user-visible behavior is unchanged.
2026-06-23 11:09:10 -04:00
vineyardbovines 36785ffce0 fix: rebase integration with main
- restore native getVideoMetadata via react-native-compressor so
  draft-restore on native still works after pickVideo.ts removal
- widen metadata signatures to File | string to match call sites
- mark conversion.cancel() with void to satisfy no-floating-promises
- pnpm install for mediabunny
2026-06-18 17:59:36 -04:00
Samuel Newman 50fd127373 dispose of input once finished 2026-06-18 17:51:19 -04:00
Samuel Newman 1e8eccffd2 process videos on web with webcodecs 2026-06-18 17:51:19 -04:00
Eric Bailey dd452a4336 Fix nightly build workflow (#10943)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:13:41 -05:00
Eric Bailey 1a8ca710ee Bump version to 1.126.0 (#10942) 2026-06-17 17:56:51 -05:00
Eric Bailey 6f916902e7 Add nightly iOS/Android build workflow (#10940)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:53:08 -05:00
Samuel Newman d10137fd78 Simplify Android build: bundletool + eas submit (#9855)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-17 16:42:55 -05:00
Samuel Newman 2347d4f074 v1.125 release prep (#10936) 2026-06-17 05:28:47 -07:00
Alex Benzer b368cea557 Style follow buttons in expanded bundles, add client event on expand (#10915) 2026-06-17 03:13:57 -07:00
DS Boyce 728ee15e27 Flash message bubble when tapping a reply (#10929) 2026-06-17 03:11:56 -07:00
Samuel Newman 6af477bcc2 Replace setup-cocoapods action with inline version assert (#10932)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:51:59 -07:00
Samuel Newman 1b5242c1d6 Rip out Modal system completely (#9903) 2026-06-17 02:45:01 -07:00
DS Boyce 6bf1572ffc Increase chat bubble max width to 85% (was 80%) (#10930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-17 02:29:05 -07:00
pfrazee c3a110a939 Nightly source-language update 2026-06-17 03:20:23 +00:00
Spence Pope f92712f34d Persist selected QR code color theme in invite friends dialog (#10892) 2026-06-16 20:07:50 -04:00
Eric Bailey 9825057bef Fix for AutosizedTextarea not resetting height after clear on Android (#10927) 2026-06-16 16:16:27 -05:00
DS Boyce e057d3bd54 Get unread counts vs counting convos (#10878) 2026-06-16 13:45:07 -07:00
Samuel Newman b90d3108ed Fix quote causing chat message to overflow on web (#10926) 2026-06-16 13:39:08 -07:00
Eric Bailey 255af85428 Ensure text composed via IME is visible (#10925) 2026-06-16 14:44:28 -05:00
Samuel Newman b8a6a8c8ce Pin every action to latest SHA (#10779)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 14:35:19 -05:00
Samuel Newman 4c34149106 Delete gallery fallback (#10922) 2026-06-16 11:44:27 -07:00
Samuel Newman 2257e834dc Run eslint --fix (#10923) 2026-06-16 11:44:00 -07:00
Samuel Newman 8d4f59a146 Fix DM embeds being dropped when sending (#10924)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:43:49 -07:00
Samuel Newman 721965e67c Swipe to reply in chat (#10920)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:38:36 -07:00
Samuel Newman bb6847f396 Replace Add User to Lists Modal with modern Dialog (#9677)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-16 12:41:05 -05:00
Samuel Newman b8fdce6478 Message replies in chat (#10903)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:26:48 -07:00
DS Boyce f075187e57 Reset textarea height after clear (#10911) 2026-06-16 09:25:34 -07:00
dependabot[bot] 9dc73d2ec7 Bump vite from 8.0.12 to 8.0.16 in /bskyembed (#10912)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 07:36:59 -07:00
pfrazee 1c2fd5b280 Nightly source-language update 2026-06-16 03:20:12 +00:00
Eric Bailey d7e285aa24 Fix over-eager URL faceting in DMs composer (#10877) 2026-06-15 13:38:59 -05:00
Samuel Newman fdd08330f7 [Chat] Hide unread state for selected chat list item (#10910)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:16:20 -07:00
Samuel Newman 042fdf659d Use pnpm 11.7.0 (#10900) 2026-06-15 10:16:07 -07:00
Benjamin Wingerter 6dcb4c9a1f fix: truncate long display names in group chat member picker (#10899) (#10901) 2026-06-15 04:46:43 -07:00
Samuel Newman d20d021173 [Chat] Fix member count in SearchablePeopleList (#10909) 2026-06-15 02:47:43 -07:00
Samuel Newman 0204848cc4 [Chat] fix: enforce convo-present invariant for active convo states (#10906)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 02:47:14 -07:00
Samuel Newman 02a47fb4a6 Fix scroll edge effects on iOS 27 (#10907) 2026-06-14 07:07:33 -07:00
pfrazee 575b76a71e Nightly source-language update 2026-06-14 03:20:57 +00:00
Samuel Newman feff61a031 Add long-press menu to Chat tab for marking all as read (#10890) 2026-06-13 05:23:28 -07:00
pfrazee e1f86e49e7 Nightly source-language update 2026-06-13 03:18:35 +00:00
DS Boyce 6028532981 Update OTA docs (#10895) 2026-06-12 16:03:12 -07:00
DS Boyce 825d25847c Fix pluralization for group chat member counts (#10896) 2026-06-13 01:01:13 +03:00
Samuel Newman cd404023fb Fix chat firehose event handling and error recovery (#10884) 2026-06-13 01:01:00 +03:00
Samuel Newman 52b945b7be [Chat] Restructure MessageItem to prevent alignment issues (#10888) 2026-06-13 00:59:50 +03:00
Samuel Newman 9c346e5494 [Chat] Fix block dialog overflow issues (#10887) 2026-06-13 00:59:37 +03:00
Samuel Newman 714b1e205d Fix invite card avatar pile not reflecting member count (#10891) 2026-06-13 00:57:31 +03:00
Samuel Newman 1f90a4dfd9 [Chat] Change chat invite border color to low (#10886) 2026-06-13 00:57:16 +03:00
Samuel Newman 7310426ea7 [Chat] Hide blocked accounts' reactions in chats (#10889)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:19:22 -07:00
Alex Benzer 581d2b296d Fixes follow buttons inside notification bundles not changing state on follow (#10882) 2026-06-12 11:18:53 -07:00
DS Boyce ec695a42c5 Upgrade to pnpm 11.5.3 (#10879) 2026-06-12 00:01:41 -07:00
Austin McKinley 6c04170734 Replace broken @claude workflow with Bedrock-based claude-review/mention (#10880)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:36:10 -07:00
pfrazee bce0b51c2e Nightly source-language update 2026-06-12 03:20:29 +00:00
Spence Pope 0a9f7423e2 Keep lightbox image index on device rotation (#10867) 2026-06-11 15:58:11 -05:00
Samuel Newman f0e20d4c56 Make StandardSiteEmbed peekable (#10866) 2026-06-11 15:49:09 -05:00
Samuel Newman b281e23f11 Bump version to v1.125.0 (#10876) 2026-06-11 22:29:18 +03:00
Samuel Newman bd775070e8 [Chat] Use relatedprofiles for reactions/new message logs (#10875) 2026-06-11 11:12:11 -07:00
DS Boyce 3d3d7ba675 Fix cursor position when editing a group chat name (#10872) 2026-06-11 11:06:39 -07:00
DS Boyce f96e6ab985 Bump font size for disabled invite link dialog text (#10874) 2026-06-11 11:06:23 -07:00
DS Boyce d57eeb986b Fix followed by chat invites (#10873) 2026-06-11 11:05:07 -07:00
DS Boyce 91053e3f70 Optimistically update chat invite button status (#10870) 2026-06-11 09:48:23 -07:00
DS Boyce b0eaecb01b Fix Rescind request button (#10869) 2026-06-11 09:45:08 -07:00
Samuel Newman c96d63fa5f [Chat] Update bskyogcard lexicon version (#10871) 2026-06-11 09:34:18 -07:00
Samuel Newman bd034ac1a8 [Chat] Update single-convo cache on firehose mute/unmute events (#10865)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:08:28 -07:00
Spence Pope ba72110a12 Fix overlapping touch targets for profile header buttons (#10868) 2026-06-11 12:04:46 -04:00
Spence Pope 302026d684 [bskyembed] Add gallery embed support (#10818) 2026-06-11 12:02:20 -04:00
DS Boyce c8634105c1 Run codemod for replacing BskyAgent with AtpAgent (#10862) 2026-06-11 08:46:01 -07:00
DS Boyce e3e7db2350 Redirect to messages after leaving a chat (#10861) 2026-06-11 08:45:37 -07:00
DS Boyce fbd81ad025 Change Users I follow to People I follow (#10860) 2026-06-11 08:45:16 -07:00
233 changed files with 38411 additions and 33390 deletions
+63
View File
@@ -0,0 +1,63 @@
You are an experienced senior React Native engineer reviewing a pull
request in the Bluesky Social app — a cross-platform (iOS, Android, Web)
React Native + Expo application. Read the repo's CLAUDE.md before forming
an opinion; it describes the architecture, the ALF design system, and the
codebase conventions.
Your audience is other senior engineers. Write peer-to-peer, not
teacher-to-junior. Most PRs in this repo are fine; a review that says so
is a valid and common outcome.
Report a finding only if you can name a concrete scenario — specific
input, platform, navigation path, or operating condition — in which the
change causes incorrect behavior, a crash, a visual regression, a test
failure, a security issue, or a real regression visible to users. Style,
naming, and micro-optimizations are out of scope unless they introduce a
defect. Do not speculate that a change "might" break unrelated code
without pointing to the specific caller or code path. Do not repeat what
the diff does.
Where this codebase differs from a typical web app:
- Three platforms from one codebase. Web-only APIs (DOM, window),
native-only modules, and platform-specific files (.web.tsx, .ios.tsx,
.android.tsx) are common sources of single-platform breakage. When a
change touches shared code, consider all three targets.
- User-facing strings must go through Lingui (the `Trans` macro /
`useLingui`). Hardcoded English strings in UI are a finding. Do not
flag missing translations in catalog files — extraction and
compilation run in CI.
- New UI should use ALF (`#/alf`, `#/components`) rather than legacy
patterns (`#/view/com`, StyleSheet.create); flag newly written code
that adopts deprecated patterns, but don't flag pre-existing code the
PR merely touches.
- Server state lives in TanStack Query under src/state/queries. Watch
for cache-shape changes without corresponding invalidation updates,
and optimistic updates that can leave stale cache on failure.
- List rendering is performance-critical (the main feed). Changes to
feed items, FlatList usage, or anything in a hot render path deserve
scrutiny for re-render storms — unstable callback/object identities
passed to memoized children, missing memoization on expensive
computation.
- Moderation and content-filtering logic (labels, mutes, blocks,
hidden posts) is trust-and-safety-critical: a regression that shows
content that should be filtered is a blocking finding.
- Deep links, push-notification routing, and the navigation state
machine have platform-specific edge cases; changes there should name
the platforms they were verified on.
- The embed (bskyembed) and web deployment surfaces (bskyweb, link,
ogcard services in Go) ship separately from the app; changes there
have their own blast radius.
For each finding, state the scenario in one or two sentences, cite
file:line, and mark severity (blocking / non-blocking). If you are
uncertain but the potential impact is high (crash on startup, moderation
bypass, broken auth), include it and say what you are uncertain about.
Otherwise, prefer silence over guessing.
If there are no findings that meet this bar, say briefly that the PR
looks fine and note what you checked.
Post your review as a single top-level PR comment. Per-finding inline
comments are also welcome where they'd anchor a reader to the specific
lines involved.
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -51,7 +51,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -23,13 +23,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }}
@@ -37,7 +37,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -46,7 +46,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: true
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
@@ -36,7 +36,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -45,7 +45,7 @@ jobs:
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: true
+129 -71
View File
@@ -10,6 +10,23 @@ on:
options:
- testflight-android
- production
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
outputs:
package-version:
description: Version from package.json
value: ${{ jobs.build.outputs.package-version }}
version-code:
description: Android version code
value: ${{ jobs.build.outputs.version-code }}
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs:
build:
@@ -19,6 +36,10 @@ jobs:
concurrency:
group: android-build
cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -28,32 +49,32 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: "temurin"
java-version: "17"
@@ -62,7 +83,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -92,99 +114,76 @@ jobs:
--profile $PROFILE
--local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
if: ${{ inputs.profile != 'production' }}
run: mv build.aab build.apk
- name: ⏰ Get a timestamp
id: timestamp
uses: nanzm/get-time-action@master
with:
format: "MM-DD-HH-mm-ss"
- name: 🚀 Upload Production Artifact
id: upload-artifact-production
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.aab
path: build.aab
- name: 🚀 Upload Testflight Artifact
id: upload-artifact-testflight
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🔔 Notify Slack of Production Build
- name: 🚀 Submit to Google Play
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: pnpm eas submit -p android --profile $PROFILE --non-interactive --path build.aab
- name: 🔔 Notify Slack of Play Store Submission
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v3.0.3
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
bundletool-version: "1.17.2"
- name: 🧹 Clear Metro cache
if: ${{ inputs.profile == 'production' }}
# https://github.com/expo/eas-cli/issues/2959#issuecomment-2749791326
run: rm -rf ${TMPDIR:-/tmp}/metro-cache
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile production-apk
--local --output build.apk --non-interactive
- name: 📦 Build signed universal APK
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
- name: 📦 Extract universal APK
run: unzip -p universal.zip universal.apk > build.apk
- name: ⏰ Get a timestamp
id: timestamp
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
- name: 🚀 Upload APK Artifact
id: upload-artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
- name: 🔔 Notify Slack of APK Artifact
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
@@ -195,3 +194,62 @@ jobs:
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
# build is dispatched against such a tag we attach the APK to the matching release. This runs
# as a separate job so that `contents: write` is isolated here and the build job stays read-only.
attachToRelease:
name: Attach APK to GitHub Release
runs-on: ubuntu-latest
needs: [build]
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
permissions:
contents: write
steps:
# We only attach to a release that already exists — never create one.
- name: 🔎 Check for matching GitHub Release
id: release-check
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
status=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
if [ "$status" = "200" ]; then
echo "Found GitHub Release for tag $TAG"
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "No GitHub Release found for tag $TAG (HTTP $status); skipping APK attachment."
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: ⬇️ Download APK artifact
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: ${{ needs.build.outputs.apk-artifact-name }}
- name: 🏷️ Rename APK for release
if: ${{ steps.release-check.outputs.exists == 'true' }}
run: cp build.apk "Bluesky-${{ needs.build.outputs.package-version }}.apk"
- name: 📎 Attach APK to GitHub Release
id: attach
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.ref_name }}
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
fail_on_unmatched_files: true
- name: 🔔 Notify Slack of Release Attachment
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android APK attached to GitHub Release ${{ github.ref_name }}!\n```Asset: Bluesky-${{ needs.build.outputs.package-version }}.apk\nRelease: ${{ steps.attach.outputs.url }}```"}
+106 -15
View File
@@ -10,6 +10,31 @@ on:
options:
- testflight
- production
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
outputs:
package-version:
description: Version from package.json
value: ${{ jobs.build.outputs.package-version }}
build-number:
description: iOS build number
value: ${{ jobs.build.outputs.build-number }}
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs:
build:
@@ -19,6 +44,9 @@ jobs:
concurrency:
group: ios-build
cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -28,42 +56,48 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: maxim-lobanov/setup-xcode@v1
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.16.2
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
exit 1
fi
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: pods-cache
with:
path: ./ios/Pods
@@ -75,7 +109,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
# EXPO_PUBLIC_ENV is handled in eas.json
- name: ✏️ Write environment variables
@@ -153,19 +188,75 @@ jobs:
id: get-build-info
run: bash scripts/setGitHubOutput.sh
# Read the build number straight from the IPA's CFBundleVersion. This is the value
# baked in at build time by use-build-number-with-bump (remote counter + 1) and the
# number that actually lands in App Store Connect. `eas build:version:get` reads the
# remote counter, which a --local build does not advance, so it can be off by one —
# using it here would make distribute_only poll for a nonexistent build.
- name: 🔢 Read build number from IPA
id: ipa-build-number
run: |
plist_dir="$(mktemp -d)"
unzip -o -q "$BUILD_DIR/Bluesky.ipa" 'Payload/*.app/Info.plist' -d "$plist_dir"
plist="$(find "$plist_dir" -name Info.plist -print -quit)"
build_number="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$plist")"
rm -rf "$plist_dir"
if [ -z "$build_number" ]; then
echo "ERROR: could not read CFBundleVersion from IPA"
exit 1
fi
echo "IPA build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
# eas submit only uploads to App Store Connect; it can't assign a build to a
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
if: ${{ inputs.assignTestFlightGroup }}
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
run: |
# Ensure the API key material is removed even if fastlane exits non-zero
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
trap 'rm -f asc_api_key.json' EXIT
# fastlane's Token.from_json_file expects the .p8 contents inline under "key"
# (PEM with embedded newlines), not a path. jq handles the newline escaping.
key_content="$(echo "$ASC_KEY_P8_BASE64" | base64 --decode)"
jq -n \
--arg key_id "$ASC_KEY_ID" \
--arg issuer_id "$ASC_ISSUER_ID" \
--arg key "$key_content" \
'{key_id: $key_id, issuer_id: $issuer_id, key: $key, in_house: false}' \
> asc_api_key.json
# app_platform is required in non-interactive mode: distribute_only otherwise
# calls fetch_app_platform, which prompts for input and crashes without a TTY.
fastlane run upload_to_testflight \
api_key_path:"$PWD/asc_api_key.json" \
distribute_only:true \
app_platform:"ios" \
app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \
groups:"QA Team" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"}
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
+80 -33
View File
@@ -18,6 +18,10 @@ on:
description: Runtime version (in x.x.x format) that this update is for
required: true
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
@@ -48,7 +52,7 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
@@ -56,17 +60,17 @@ jobs:
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
with:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
@@ -75,7 +79,8 @@ jobs:
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
@@ -87,7 +92,7 @@ jobs:
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
eas-version: '19.0.5'
@@ -96,7 +101,7 @@ jobs:
- name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }}
uses: dcarbone/install-jq-action@v2
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
@@ -137,7 +142,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
path: most-recent-testflight-commit.txt
@@ -157,7 +162,9 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }}
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app' }}
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -167,20 +174,20 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -189,17 +196,23 @@ jobs:
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.16.2
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
exit 1
fi
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: pods-cache
with:
path: ./ios/Pods
@@ -282,7 +295,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
@@ -303,7 +316,9 @@ jobs:
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app'}}
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app'}}
steps:
- name: Check for EXPO_TOKEN
@@ -314,26 +329,26 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: "temurin"
java-version: "17"
@@ -368,17 +383,49 @@ jobs:
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile testflight-android
--local --output build.apk --non-interactive
--local --output build.aab --non-interactive
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🚀 Submit to Google Play
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
build.aab
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with:
bundletool-version: "1.17.2"
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
- name: 📦 Build signed universal APK
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
- name: 📦 Extract universal APK
run: unzip -p universal.zip universal.apk > build.apk
- name: ⏰ Get a timestamp
id: timestamp
uses: nanzm/get-time-action@master
with:
format: "MM-DD-HH-mm-ss"
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
retention-days: 30
compression-level: 0
@@ -386,7 +433,7 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -396,7 +443,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
+76
View File
@@ -0,0 +1,76 @@
name: claude-mention
# @claude mention handler on PR conversation comments, inline review
# comments, and review bodies. See the header comment in
# claude-review.yml for why this is self-contained rather than calling
# the org reusable workflows.
#
# Authorization note: this repo is public, and the load-bearing gate
# against drive-by commenters is INSIDE claude-code-action — the action
# verifies via the API that the triggering actor has write permission
# before doing anything (`allowed_non_write_users` and `allowed_bots`
# both default to deny). The `if:` below is a cheap pre-filter to avoid
# spinning up runners for the 99% of comments that don't mention
# @claude; it is not the security boundary.
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: read
pull-requests: write
issues: write
actions: read
id-token: write
jobs:
mention:
# Skip comments/reviews that don't mention @claude, non-PR issue
# comments, and events from claude[bot] itself (its review prose
# often quotes "@claude" and must not retrigger the workflow).
if: >
(
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude'))
) &&
github.actor != 'claude[bot]'
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: claude-mention-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
with:
use_bedrock: 'true'
additional_permissions: |
actions: read
track_progress: true
claude_args: |
--model global.anthropic.claude-opus-4-8
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
+68
View File
@@ -0,0 +1,68 @@
name: claude-review
# Automatic Claude review on PR creation/update, via Bedrock (OIDC, no
# long-lived tokens). Self-contained: this intentionally uses upstream
# claude-code-action defaults rather than the org reusable workflows in
# bluesky-social/.github (which a public repo cannot call, and whose
# customizations added no value over upstream).
#
# Review guidance lives in .github/claude-review-prompt.md.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: write
actions: read
id-token: write
jobs:
review:
# Internal PRs only. This repo is public: fork PRs are the bulk of
# community traffic and MUST NOT trigger reviews (no Bedrock spend on
# unvetted code, and fork PRs can't mint the OIDC token anyway —
# belt-and-braces with this explicit guard). Branch PRs can only be
# created by people with write access, i.e. org members.
# Bot-authored PRs (dependabot, changesets) are also skipped.
if: >
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: claude-review-${{ github.repository }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude review
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
with:
use_bedrock: 'true'
additional_permissions: |
actions: read
track_progress: true
claude_args: |
--model global.anthropic.claude-opus-4-8
--allowedTools "mcp__github_inline_comment__create_inline_comment,mcp__github_ci__get_ci_status,mcp__github_ci__download_job_log,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Read .github/claude-review-prompt.md in the checked-out repo
and review this pull request following its guidance.
-54
View File
@@ -1,54 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-8
+7 -4
View File
@@ -10,14 +10,17 @@ concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go tooling
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
@@ -33,9 +36,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go tooling
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
+6 -6
View File
@@ -24,7 +24,7 @@ jobs:
job: [lint, prettier, typecheck]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Verify Node version pins match package.json
run: |
set -euo pipefail
@@ -50,9 +50,9 @@ jobs:
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "eas.json" "$v"
exit $rc
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
@@ -87,10 +87,10 @@ jobs:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
+173
View File
@@ -0,0 +1,173 @@
---
name: Nightly Build
on:
schedule:
- cron: "10 2 * * *" # run at 2:10 AM UTC, after the nightly i18n job
workflow_dispatch:
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs:
# Generate the changelog once, shared by both platforms. The range covers commits since
# the previous nightly, whose commit SHA is stored as a "nightly-build-commit" artifact
# (advanced by the record job below, only after both builds succeed).
prepare:
name: Prepare release notes
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
outputs:
notes: ${{ steps.notes.outputs.notes }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: 📝 Generate release notes
id: notes
env:
GH_TOKEN: ${{ github.token }}
run: |
# Find the most recent non-expired nightly-build-commit artifact and read its SHA
# The artifacts API returns results newest-first, so the most recent marker is on
# page 1 — no --paginate needed (which would run the jq aggregation per page and
# could emit multiple URLs). Take the first non-expired match.
prev=""
url=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=nightly-build-commit&per_page=100" \
--jq 'first(.artifacts[] | select(.expired == false)) | .archive_download_url' \
2>/dev/null || true)
if [ -n "$url" ] && [ "$url" != "null" ]; then
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o marker.zip "$url" \
&& unzip -o -q marker.zip; then
prev=$(cat nightly-build-commit.txt 2>/dev/null | tr -d '[:space:]')
fi
rm -f marker.zip nightly-build-commit.txt
fi
if [ -n "$prev" ] && git cat-file -e "${prev}^{commit}" 2>/dev/null; then
echo "Generating notes since previous nightly: $prev"
range="${prev}..HEAD"
else
echo "No reachable previous nightly commit; falling back to last 30 commits."
range="HEAD~30..HEAD"
fi
notes=$(git log --no-merges --pretty=format:'- %s' "$range" 2>/dev/null | head -n 50)
if [ -z "$notes" ]; then
notes="Nightly build — no new commits since the last nightly."
fi
# Cap the whole changelog to keep the Slack message a reasonable size.
# head -c caps the combined stream; cut -c would only cap each line independently.
notes=$(printf '%s' "$notes" | head -c 3900)
{
echo "notes<<NOTES_EOF"
echo "$notes"
echo "NOTES_EOF"
} >> "$GITHUB_OUTPUT"
ios:
name: Nightly iOS Build
needs: [prepare]
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
assignTestFlightGroup: true
secrets: inherit
android:
name: Nightly Android Build
needs: [prepare]
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
# That job is skipped for nightly (it needs a production tag build), but GitHub statically
# validates the reusable-workflow permission ceiling, so the caller must grant it here.
permissions:
contents: write
uses: ./.github/workflows/build-submit-android.yml
with:
profile: testflight-android
secrets: inherit
notify-ios:
name: Notify Slack of iOS nightly
needs: [prepare, ios]
runs-on: ubuntu-latest
steps:
- name: 📝 Build Slack payload
id: payload
env:
NOTES: ${{ needs.prepare.outputs.notes }}
VERSION: ${{ needs.ios.outputs.package-version }}
BUILD_NUMBER: ${{ needs.ios.outputs.build-number }}
run: |
text="*Nightly iOS build available in TestFlight (QA Team)*
Version ${VERSION} (${BUILD_NUMBER})
${NOTES}"
payload=$(jq -n --arg text "$text" '{text: $text}')
{
echo "payload<<PAYLOAD_EOF"
echo "$payload"
echo "PAYLOAD_EOF"
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.payload.outputs.payload }}
notify-android:
name: Notify Slack of Android nightly
needs: [prepare, android]
runs-on: ubuntu-latest
steps:
- name: 📝 Build Slack payload
id: payload
env:
NOTES: ${{ needs.prepare.outputs.notes }}
VERSION: ${{ needs.android.outputs.package-version }}
VERSION_CODE: ${{ needs.android.outputs.version-code }}
run: |
text="*Nightly Android build available (Internal track)*
Version ${VERSION} (${VERSION_CODE})
${NOTES}"
payload=$(jq -n --arg text "$text" '{text: $text}')
{
echo "payload<<PAYLOAD_EOF"
echo "$payload"
echo "PAYLOAD_EOF"
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.payload.outputs.payload }}
# Advance the nightly marker only after both builds succeed, so a failed night's commits
# roll into the next successful nightly's notes rather than being silently dropped.
record:
name: Record nightly commit
needs: [ios, android]
runs-on: ubuntu-latest
steps:
- name: ✏️ Write nightly commit marker
env:
GITHUB_SHA: ${{ github.sha }}
run: echo "$GITHUB_SHA" > nightly-build-commit.txt
- name: 🚀 Upload nightly commit marker
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-build-commit
path: nightly-build-commit.txt
retention-days: 90
@@ -16,12 +16,12 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
@@ -30,12 +30,12 @@ jobs:
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
uses: stefanzweifel/git-auto-commit-action@v5
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
with:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: Push source lang to Crowdin
uses: crowdin/github-action@v2
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_sources_args: "-b main"
+28 -16
View File
@@ -5,16 +5,15 @@ on:
issue_comment:
types: [created]
# Permissiosn to make comments in the pull request
permissions:
pull-requests: write
actions: write
contents: read
# Permissions are granted per-job below; anything unlisted defaults to none
permissions: {}
jobs:
handle-comment:
if: github.event.issue.pull_request
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-deploy: ${{ steps.check-org.outputs.result }}
@@ -45,10 +44,10 @@ jobs:
echo "mentioned=false" >> $GITHUB_OUTPUT
fi
- name: Check organization membership
- name: Check commenter has write access
if: steps.check-mention.outputs.mentioned == 'true'
id: check-org
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
@@ -72,13 +71,16 @@ jobs:
runs-on: ubuntu-latest
needs: [handle-comment]
if: needs.handle-comment.outputs.should-deploy == 'true'
permissions:
contents: read
pull-requests: write
steps:
- name: Get PR HEAD SHA
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = await github.rest.pulls.get({
@@ -87,6 +89,16 @@ jobs:
pull_number: process.env.ISSUE_NUMBER,
});
// This workflow runs with repo secrets in scope, so never build
// code from a fork: the commenter authorizes the deploy, but a
// fork controls what code would run during it
const expected = `${context.repo.owner}/${context.repo.repo}`;
const head = pr.data.head.repo?.full_name;
if (head !== expected) {
core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`);
return;
}
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
@@ -94,7 +106,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -113,14 +125,14 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
@@ -141,14 +153,14 @@ jobs:
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
- name: Env
id: env
@@ -181,7 +193,7 @@ jobs:
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
@@ -198,7 +210,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+22 -16
View File
@@ -13,25 +13,28 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write
actions: write
contents: read
# Permissions are granted per-job below; anything unlisted defaults to none.
# pull-requests: write is needed by sticky-pull-request-comment to post the
# bundle-size and fingerprint diffs
permissions: {}
jobs:
webpack-analyzer:
runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
pull-requests: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
@@ -68,7 +71,7 @@ jobs:
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
@@ -89,14 +92,14 @@ jobs:
- name: Get diff
id: get-diff
uses: NejcZdovc/bundle-size-diff@v1
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
with:
base_path: "stats-base.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: bundle-diff
message: |
@@ -108,9 +111,12 @@ jobs:
fingerprint-native:
runs-on: ubuntu-22.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
pull-requests: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 100
@@ -118,22 +124,22 @@ jobs:
run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request'
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@main
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
with:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -151,7 +157,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+9 -2
View File
@@ -4,22 +4,29 @@ on:
push:
branches: [main]
# The push to the internal repo uses the app token below, not the GITHUB_TOKEN
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal
# Scope the token down from the app's full installation permissions;
# pushing is the only thing this token is used for
permission-contents: write
- name: Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
+6 -3
View File
@@ -6,13 +6,16 @@ concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
permissions:
contents: read
jobs:
verify-pnpm-lock:
name: No manual pnpm-lock.yaml edits
runs-on: ubuntu-latest
steps:
- name: Check out PR HEAD
uses: actions/checkout@v5
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
@@ -21,10 +24,10 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
+37
View File
@@ -0,0 +1,37 @@
name: Workflow security
on:
push:
branches: [main]
paths: [".github/**"]
pull_request:
paths: [".github/**"]
concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
# The github.token is only used by zizmor's online audits (read-only API calls)
permissions:
contents: read
jobs:
zizmor:
name: Audit workflows with zizmor
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
# Annotate the PR directly instead of uploading SARIF to the
# security tab, and fail the check on any finding
advanced-security: false
annotations: true
# Low-confidence findings (e.g. artipacked on workflows that never
# push) are too noisy to gate CI on
min-confidence: medium
+62
View File
@@ -0,0 +1,62 @@
/**
* Codemod to replace BskyAgent with AtpAgent
*
* Before:
* import {BskyAgent} from '@atproto/api`
* BskyAgent.appLabelers.includes(labeler)
*
* After:
* import {AtpAgent} from '@atproto/api`
* AtpAgent.appLabelers.includes(labeler)
*
* Handles import specifiers, type annotations, static member access
* (BskyAgent.configure), `extends BskyAgent`, and `new BskyAgent()`. Whole
* identifiers only, so names like `OpaqueBskyAgent` are left untouched.
*
* Usage: jscodeshift -t .jscodeshift/repo/bsky-agent.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/bsky-agent.js src/lib/moderation.ts
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Replace every standalone `BskyAgent` identifier with `AtpAgent`. This
// covers imports, type references, member expressions, `extends`, and `new`.
root
.find(j.Identifier, {name: 'BskyAgent'})
.replaceWith(() => j.identifier('AtpAgent'))
// Renaming can leave a duplicate `AtpAgent` specifier on the @atproto/api
// import if the file already imported it. Dedupe by imported name, keeping
// the type-only modifier only if every duplicate was type-only.
root
.find(j.ImportDeclaration, {source: {value: '@atproto/api'}})
.forEach(path => {
const seen = new Map()
for (const spec of path.value.specifiers) {
if (spec.type !== 'ImportSpecifier') {
seen.set(Symbol(), spec)
continue
}
const name = spec.imported.name
const existing = seen.get(name)
if (!existing) {
seen.set(name, spec)
} else if (
existing.importKind === 'type' &&
spec.importKind !== 'type'
) {
// Prefer the value (non-type) import if either usage needs it.
seen.set(name, spec)
}
}
path.value.specifiers = Array.from(seen.values())
})
return root.toSource()
}
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.1.3
RUN npm install --global pnpm@11.5.2
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard ./
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.5.2 && \
npm install --global pnpm@11.7.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+4 -4
View File
@@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app
id: "profileHeaderDropdownBtn"
- tapOn: "Add to Lists"
- assertVisible:
id: "userAddRemoveListsModal"
id: "userAddRemoveListsDialog"
- tapOn:
id: "user-bob.test-addBtn"
- tapOn:
id: "doneBtn"
- assertNotVisible:
id: "userAddRemoveListsModal"
id: "userAddRemoveListsDialog"
- tapOn:
id: "profileHeaderDropdownBtn"
- tapOn: "Add to Lists"
- assertVisible:
id: "userAddRemoveListsModal"
id: "userAddRemoveListsDialog"
- tapOn:
id: "user-bob.test-addBtn"
- tapOn:
id: "doneBtn"
- assertNotVisible:
id: "userAddRemoveListsModal"
id: "userAddRemoveListsDialog"

Before

Width:  |  Height:  |  Size: 334 B

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z"/></svg>

After

Width:  |  Height:  |  Size: 273 B

+2 -2
View File
@@ -13,7 +13,7 @@
"format": "prettier -w src"
},
"dependencies": {
"@atproto/api": "^0.15.25",
"@atproto/api": "0.20.11",
"preact": "^10.4.8"
},
"devDependencies": {
@@ -32,7 +32,7 @@
"terser": "^5.43.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.12",
"vite": "^8.0.16",
"vite-bundle-analyzer": "^1.3.8"
},
"resolutions": {
+368 -197
View File
File diff suppressed because it is too large Load Diff
+66 -18
View File
@@ -1,5 +1,6 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
@@ -43,6 +44,11 @@ export function Embed({
return <ImageEmbed content={content} labelInfo={labelInfo} />
}
// Case 1b: Gallery (Photos v2)
if (AppBskyEmbedGallery.isView(content)) {
return <GalleryEmbed content={content} labelInfo={labelInfo} />
}
// Case 2: External link
if (AppBskyEmbedExternal.isView(content)) {
return <ExternalEmbed content={content} labelInfo={labelInfo} />
@@ -230,6 +236,8 @@ function Info({children}: {children: ComponentChildren}) {
)
}
type GridImage = {thumb: string; alt: string}
function ImageEmbed({
content,
labelInfo,
@@ -240,20 +248,45 @@ function ImageEmbed({
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
return (
<ImageGrid
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
/>
)
}
switch (content.images.length) {
function GalleryEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedGallery.View
labelInfo?: string
}) {
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
const images = content.items
.filter(AppBskyEmbedGallery.isViewImage)
.map(i => ({thumb: i.thumbnail, alt: i.alt}))
return <ImageGrid images={images} />
}
function ImageGrid({images}: {images: GridImage[]}) {
switch (images.length) {
case 0:
return null
case 1:
return (
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
src={images[0].thumb}
alt={images[0].alt}
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => (
{images.map((image, i) => (
<img
key={i}
src={image.thumb}
@@ -268,13 +301,13 @@ function ImageEmbed({
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex-1 aspect-square">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
src={images[0].thumb}
alt={images[0].alt}
className="w-full h-full object-cover rounded-sm"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
{content.images.slice(1).map((image, i) => (
{images.slice(1).map((image, i) => (
<img
key={i}
src={image.thumb}
@@ -285,21 +318,36 @@ function ImageEmbed({
</div>
</div>
)
case 4:
default: {
const remaining = images.length - 4
return (
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-[3/2] w-full object-cover rounded-sm"
/>
))}
{images.slice(0, 4).map((image, i) => {
const isOverflowCell = i === 3 && remaining > 0
return (
<div
key={i}
className="relative aspect-[3/2] rounded-sm overflow-hidden">
<img
src={image.thumb}
alt={image.alt}
className="absolute inset-0 w-full h-full object-cover"
/>
{isOverflowCell && (
<div
aria-label={`+${remaining} more image${remaining === 1 ? '' : 's'}, view post to see all`}
className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-white text-2xl font-semibold">
+{remaining}
</span>
</div>
)}
</div>
)
})}
</div>
)
default:
return null
}
}
}
+3 -1
View File
@@ -154,7 +154,9 @@ export function Post({thread}: Props) {
}
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
if (!record) return null
// text-only check - posts with no text (e.g. gallery posts) would otherwise
// render an empty <p> that adds an extra flex gap above the embed
if (!record?.text) return null
const rt = new RichText({
text: record.text,
+2 -2
View File
@@ -6,7 +6,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.1.3",
"version": "11.5.2",
"onFail": "warn"
}
},
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/api": "0.20.13",
"@atproto/common": "^0.6.1",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
+43 -43
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.1.3
version: 11.1.3
specifier: 11.5.2
version: 11.5.2
pnpm:
specifier: 11.1.3
version: 11.1.3
specifier: 11.5.2
version: 11.5.2
packages:
'@pnpm/exe@11.1.3':
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
hasBin: true
'@pnpm/linux-arm64@11.1.3':
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.1.3':
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.1.3':
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.1.3':
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.1.3':
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.1.3':
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.1.3':
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.1.3:
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
pnpm@11.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.1.3':
'@pnpm/exe@11.5.2':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.1.3
'@pnpm/linux-x64': 11.1.3
'@pnpm/linuxstatic-arm64': 11.1.3
'@pnpm/linuxstatic-x64': 11.1.3
'@pnpm/macos-arm64': 11.1.3
'@pnpm/win-arm64': 11.1.3
'@pnpm/win-x64': 11.1.3
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@pnpm/linux-arm64@11.1.3':
'@pnpm/linux-arm64@11.5.2':
optional: true
'@pnpm/linux-x64@11.1.3':
'@pnpm/linux-x64@11.5.2':
optional: true
'@pnpm/linuxstatic-arm64@11.1.3':
'@pnpm/linuxstatic-arm64@11.5.2':
optional: true
'@pnpm/linuxstatic-x64@11.1.3':
'@pnpm/linuxstatic-x64@11.5.2':
optional: true
'@pnpm/macos-arm64@11.1.3':
'@pnpm/macos-arm64@11.5.2':
optional: true
'@pnpm/win-arm64@11.1.3':
'@pnpm/win-arm64@11.5.2':
optional: true
'@pnpm/win-x64@11.1.3':
'@pnpm/win-x64@11.5.2':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.1.3: {}
pnpm@11.5.2: {}
---
lockfileVersion: '9.0'
@@ -208,8 +208,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.11
version: 0.20.11
specifier: 0.20.13
version: 0.20.13
'@atproto/common':
specifier: ^0.6.1
version: 0.6.1
@@ -259,8 +259,8 @@ importers:
packages:
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
'@atproto/api@0.20.13':
resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1154,7 +1154,7 @@ packages:
snapshots:
'@atproto/api@0.20.11':
'@atproto/api@0.20.13':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+4 -1
View File
@@ -57,6 +57,9 @@ release or OTA.
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
values you found in **Step 1**.
> [!NOTE]
> If you dont already have the EAS CLI, you can install it with `pnpm add -g eas-cli`.
```sh
> npx eas build:version:set -p ios
>
@@ -114,7 +117,7 @@ To confirm this, as mentioned above, you must completely clear the TestFlight
build from your device and re-install from the App Store. Then, you'll need to:
- Launch the app (or quit and reopen) and wait ~15s for the download to complete
- Quit and reopen the app
- Check the `Settings > About` page and confirm the has matches the most recent has on your OTA branch
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
### 9. Reset build numbers
+2 -2
View File
@@ -6,7 +6,7 @@ values.
Install dependencies in `/dev-env`
```
cd dev-env && yarn
cd dev-env && pnpm i
```
## Using Maestro
@@ -27,7 +27,7 @@ adb reverse tcp:3000 tcp:3000
- In one tab, run `pnpm e2e:mock-server`
- In a second tab, run `pnpm e2e:build`
- In a third tab, run `pnpm e2e:run`
- In a third tab, run `pnpm e2e:run __e2e__`
## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
+8 -16
View File
@@ -47,21 +47,6 @@
"EXPO_PUBLIC_ENV": "production"
}
},
"production-apk": {
"extends": "base",
"distribution": "internal",
"ios": {
"autoIncrement": false,
"buildArtifactPaths": ["ios/build/*"]
},
"android": {
"autoIncrement": false
},
"channel": "production",
"env": {
"EXPO_PUBLIC_ENV": "production"
}
},
"testflight": {
"extends": "base",
"ios": {
@@ -78,7 +63,6 @@
},
"testflight-android": {
"extends": "base",
"distribution": "internal",
"ios": {
"autoIncrement": true,
"buildArtifactPaths": ["ios/build/*"]
@@ -96,6 +80,14 @@
"production": {
"ios": {
"ascAppId": "6444370199"
},
"android": {
"track": "production"
}
},
"testflight-android": {
"android": {
"track": "internal"
}
}
}
+1 -49
View File
@@ -617,11 +617,6 @@
"count": 3
}
},
"src/components/dialogs/InAppBrowserConsent.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/components/dialogs/LanguageSelectDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -629,7 +624,7 @@
},
"src/components/dialogs/LinkWarning.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
"count": 1
}
},
"src/components/dialogs/MutedWords.tsx": {
@@ -848,11 +843,6 @@
"count": 1
}
},
"src/features/liveNow/components/LiveStatusDialog.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/features/liveNow/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
@@ -975,14 +965,6 @@
"count": 4
}
},
"src/lib/hooks/useOpenLink.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/usePermissions.ts": {
"@typescript-eslint/no-misused-promises": {
"count": 1
@@ -1854,11 +1836,6 @@
"count": 1
}
},
"src/state/queries/list-memberships.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/state/queries/list.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 7
@@ -2181,20 +2158,6 @@
"count": 2
}
},
"src/view/com/lists/ListMembers.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/view/com/lists/MyLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2226,9 +2189,6 @@
},
"@typescript-eslint/require-await": {
"count": 1
},
"react-hooks/refs": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
@@ -2394,9 +2354,6 @@
"src/view/com/util/Link.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-misused-promises": {
"count": 2
}
},
"src/view/com/util/LoadingPlaceholder.tsx": {
@@ -2492,11 +2449,6 @@
"count": 2
}
},
"src/view/screens/Storybook/Storybook.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/view/shell/Drawer.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
+1 -1
View File
@@ -1,4 +1,4 @@
import {BottomSheetViewProps} from './BottomSheet.types'
import {type BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheet(_: BottomSheetViewProps) {
throw new Error('BottomSheet is not available on web')
@@ -1,6 +1,6 @@
import React from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext {
@@ -1,6 +1,6 @@
import {requireNativeModule} from 'expo-modules-core'
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
import {type ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
export const BackgroundNotificationHandler =
requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
@@ -1,6 +1,6 @@
import {
BackgroundNotificationHandlerPreferences,
ExpoBackgroundNotificationHandlerModule,
type BackgroundNotificationHandlerPreferences,
type ExpoBackgroundNotificationHandlerModule,
} from './ExpoBackgroundNotificationHandler.types'
// Stub for web
@@ -2,7 +2,7 @@ import React from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {GifViewProps} from './GifView.types'
import {type GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
@@ -1,7 +1,7 @@
import {Platform} from 'react-native'
import {requireNativeModule} from 'expo-modules-core'
import {AudioCategory} from './types'
import {type AudioCategory} from './types'
const NativeModule = requireNativeModule('ExpoPlatformInfo')
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types'
import {type AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean {
throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types'
import {type AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean {
if (typeof window === 'undefined') {
@@ -1,6 +1,6 @@
import {requireNativeModule} from 'expo'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
@@ -1,6 +1,6 @@
import {SharedPrefs} from '../../index'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
@@ -1,7 +1,7 @@
import {Platform} from 'react-native'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
import {type GooglePlayReferrerInfo, type ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
@@ -1,5 +1,5 @@
import {NotImplementedError} from '../NotImplemented'
import {VisibilityViewProps} from './types'
import {type VisibilityViewProps} from './types'
export async function updateActiveViewAsync() {
throw new NotImplementedError()
@@ -1,4 +1,4 @@
import React from 'react'
import type React from 'react'
export interface VisibilityViewProps {
children: React.ReactNode
onChangeStatus: (isActive: boolean) => void
+10 -9
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.124.0",
"version": "1.126.0",
"private": true,
"engines": {
"node": ">=24.15.0"
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"version": "11.7.0",
"onFail": "warn"
},
"runtime": {
@@ -90,10 +90,11 @@
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
"icons:optimize": "svgo -f ./assets/icons",
"prettier": "prettier --check ."
"prettier": "prettier --check .",
"update-actions": "pnpm dlx actions-up --min-age 7"
},
"dependencies": {
"@atproto/api": "0.20.12",
"@atproto/api": "0.20.15",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
@@ -103,12 +104,11 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.0",
"@bsky.app/peek-menu": "^0.3.1",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.5",
@@ -204,6 +204,7 @@
"lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
@@ -331,7 +332,7 @@
"^await-lock$": "<rootDir>/node_modules/await-lock/build/AwaitLock.js"
},
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|@discord|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
@@ -1,13 +0,0 @@
diff --git a/src/hooks/useStableCallback.ts b/src/hooks/useStableCallback.ts
index 1c788ab72351c21c5aea178d7b416741aa940e1f..d30f330ab1f082ea0477c19e2fa05b7a0b1a3e0d 100644
--- a/src/hooks/useStableCallback.ts
+++ b/src/hooks/useStableCallback.ts
@@ -6,7 +6,7 @@ type Callback = (...args: any[]) => any;
* https://gist.github.com/JakeCoxon/c7ebf6e6496f8468226fd36b596e1985
*/
export const useStableCallback = (callback: Callback) => {
- const callbackRef = useRef<Callback>();
+ const callbackRef = useRef<Callback>(undefined);
const memoCallback = useCallback(
(...args: any) => callbackRef.current && callbackRef.current(...args),
[]
+83 -116
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.2
version: 11.5.2
specifier: 11.7.0
version: 11.7.0
pnpm:
specifier: 11.5.2
version: 11.5.2
specifier: 11.7.0
version: 11.7.0
packages:
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
'@pnpm/exe@11.7.0':
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==}
hasBin: true
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
'@pnpm/linux-arm64@11.7.0':
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
'@pnpm/linux-x64@11.7.0':
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
'@pnpm/linuxstatic-arm64@11.7.0':
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
'@pnpm/linuxstatic-x64@11.7.0':
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
'@pnpm/macos-arm64@11.7.0':
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
'@pnpm/win-arm64@11.7.0':
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
'@pnpm/win-x64@11.7.0':
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
pnpm@11.7.0:
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.5.2':
'@pnpm/exe@11.7.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@pnpm/linux-arm64': 11.7.0
'@pnpm/linux-x64': 11.7.0
'@pnpm/linuxstatic-arm64': 11.7.0
'@pnpm/linuxstatic-x64': 11.7.0
'@pnpm/macos-arm64': 11.7.0
'@pnpm/win-arm64': 11.7.0
'@pnpm/win-x64': 11.7.0
'@pnpm/linux-arm64@11.5.2':
'@pnpm/linux-arm64@11.7.0':
optional: true
'@pnpm/linux-x64@11.5.2':
'@pnpm/linux-x64@11.7.0':
optional: true
'@pnpm/linuxstatic-arm64@11.5.2':
'@pnpm/linuxstatic-arm64@11.7.0':
optional: true
'@pnpm/linuxstatic-x64@11.5.2':
'@pnpm/linuxstatic-x64@11.7.0':
optional: true
'@pnpm/macos-arm64@11.5.2':
'@pnpm/macos-arm64@11.7.0':
optional: true
'@pnpm/win-arm64@11.5.2':
'@pnpm/win-arm64@11.7.0':
optional: true
'@pnpm/win-x64@11.5.2':
'@pnpm/win-x64@11.7.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.5.2: {}
pnpm@11.7.0: {}
---
lockfileVersion: '9.0'
@@ -215,7 +215,6 @@ overrides:
react-native-screens: 4.24.0
patchedDependencies:
'@discord/bottom-sheet@4.6.1': 253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
@@ -242,8 +241,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.12
version: 0.20.12
specifier: 0.20.15
version: 0.20.15
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -272,23 +271,20 @@ importers:
specifier: ^0.2.9
version: 0.2.9(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/peek-menu':
specifier: ^0.3.0
version: 0.3.0(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)
specifier: ^0.3.1
version: 0.3.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)
'@bsky.app/react-native-mmkv':
specifier: 2.12.5
version: 2.12.5(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/sift':
specifier: ^0.3.8
version: 0.3.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-safe-area-context@5.6.2(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)
specifier: ^0.3.9
version: 0.3.9(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-safe-area-context@5.6.2(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/tapper':
specifier: ^0.5.7
version: 0.5.7(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)
specifier: ^0.6.1
version: 0.6.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)(tlds@1.261.0)
'@bsky.app/video':
specifier: 0.3.6
version: 0.3.6(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)
'@discord/bottom-sheet':
specifier: bluesky-social/react-native-bottom-sheet
version: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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))(@types/react@19.1.17)(react-native-gesture-handler@2.28.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-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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)
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
@@ -574,6 +570,9 @@ importers:
lodash.throttle:
specifier: ^4.1.1
version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats:
specifier: ^13.4.2
version: 13.4.2
@@ -877,8 +876,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.12':
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==}
'@atproto/api@0.20.15':
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1659,8 +1658,8 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/peek-menu@0.3.0':
resolution: {integrity: sha512-F51CT7xawnzoIOGA86E5VNYuWCItS1DnQr/J3eRE62E5+lbuCixSK/mQ4oqVsfQbKuYfAqMsBJjCgpCkdZTqTQ==}
'@bsky.app/peek-menu@0.3.1':
resolution: {integrity: sha512-ttFE7dIv1EJEmQn52WLvievzLNmPNNvPtoiz8ovl38jXLDMszL2wVPUYMnEjnZZqoq+K6HiBau9iHeCOKWwbTA==}
peerDependencies:
expo: '*'
react: '*'
@@ -1672,20 +1671,21 @@ packages:
react: '*'
react-native: '>=0.71.0'
'@bsky.app/sift@0.3.8':
resolution: {integrity: sha512-41Ug2QfjWJUApHsxcm6JDTb4r8jS6Qn6uBca8ofL10ZDKzXbSxDr1vK2yhFLyIeo/yIq3GU+XgEqrd1p6YgFiA==}
'@bsky.app/sift@0.3.9':
resolution: {integrity: sha512-EmwK6EGtxl6Mz4Z96J2EFi5aeiKeGiqueVCQHVms9C6ms6wMS0/HcqC/48RgFN+nSsM7Wr8sFCg1EcNnWyOQMQ==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
react-native-safe-area-context: ~5.6.0
'@bsky.app/tapper@0.5.7':
resolution: {integrity: sha512-DWU4v+llcEdgwueittiCSMeahrwvQAz4g1S7M1gSSw3Zor987fF7WO/NTDWq7X/zFab2nOQ/lnCvQUuOgFCTRg==}
'@bsky.app/tapper@0.6.1':
resolution: {integrity: sha512-+suwTIQcZmMbFb/adzWAlAs7gzRsMmHAerzrpzZfMtt2cVrwPgOqI5IOeEEO8SmvEEImpE8IcHindG8hkCZrKA==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
tlds: '*'
'@bsky.app/video@0.3.6':
resolution: {integrity: sha512-Ny7vz9v1lQOw5Llw4xejtJo/Dp/lmdRw1XdUJucOXlq6puXO93tv3mNXUIIyOEVWEwDPg8j/kSQxG/mGy+/hdQ==}
@@ -1698,23 +1698,6 @@ packages:
resolution: {integrity: sha512-JRrHvitc0w+r/h6XXSOq917HxsjRoxPEtyNYSmylKirjiPNrRx84VKtqFlOIWIFCOZI5BoXg6hNpE0Zq7CwVBA==}
hasBin: true
'@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908':
resolution: {gitHosted: true, integrity: sha512-zsoB8sFUx8O9aO0fKAkzoWLMyaX7EBd3ooNYjzsh9vr/c9zg/a3jOAbWzWbvM86QCZIszfbW9GXeJZKZRVk/tg==, tarball: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908}
version: 4.6.1
peerDependencies:
'@shopify/flash-list': '*'
'@types/react': '*'
'@types/react-native': '*'
react: '*'
react-native: '*'
react-native-gesture-handler: '>=1.10.1'
react-native-reanimated: 3.19.1
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-native':
optional: true
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
@@ -2106,12 +2089,6 @@ packages:
'@formatjs/intl-supportedvaluesof@2.3.7':
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
'@gorhom/portal@1.0.14':
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
peerDependencies:
react: '*'
react-native: '*'
'@growthbook/growthbook-react@1.6.5':
resolution: {integrity: sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==}
engines: {node: '>=10'}
@@ -3366,13 +3343,6 @@ packages:
peerDependencies:
webpack: '>=4.40.0'
'@shopify/flash-list@2.3.1':
resolution: {integrity: sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==}
peerDependencies:
'@babel/runtime': '*'
react: '*'
react-native: '*'
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -3527,6 +3497,12 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6882,6 +6858,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -9493,7 +9472,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.12':
'@atproto/api@0.20.15':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10479,7 +10458,7 @@ snapshots:
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)
'@bsky.app/peek-menu@0.3.0(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/peek-menu@0.3.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: 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
@@ -10490,18 +10469,19 @@ snapshots:
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)
'@bsky.app/sift@0.3.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-safe-area-context@5.6.2(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/sift@0.3.9(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-safe-area-context@5.6.2(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)
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-native-safe-area-context: 5.6.2(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/tapper@0.5.7(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/tapper@0.6.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)(tlds@1.261.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)
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)
tlds: 1.261.0
'@bsky.app/video@0.3.6(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:
@@ -10519,18 +10499,6 @@ snapshots:
transitivePeerDependencies:
- encoding
? '@discord/bottom-sheet@https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=253965341bfbc8e63630dfca511c5d0e0878900e5fe414b2278bd322fc215e2a)(@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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))(@types/react@19.1.17)(react-native-gesture-handler@2.28.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-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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:
'@gorhom/portal': 1.0.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)
'@shopify/flash-list': 2.3.1(@babel/runtime@7.29.2)(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)
invariant: 2.2.4
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-native-gesture-handler: 2.28.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-reanimated: 3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.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)
optionalDependencies:
'@types/react': 19.1.17
'@discoveryjs/json-ext@0.5.7': {}
'@egjs/hammerjs@2.0.17':
@@ -11101,12 +11069,6 @@ snapshots:
dependencies:
'@formatjs/fast-memoize': 3.1.5
'@gorhom/portal@1.0.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)':
dependencies:
nanoid: 3.3.12
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)
'@growthbook/growthbook-react@1.6.5(react@19.1.0)':
dependencies:
'@growthbook/growthbook': 1.6.5
@@ -12614,12 +12576,6 @@ snapshots:
- encoding
- supports-color
'@shopify/flash-list@2.3.1(@babel/runtime@7.29.2)(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:
'@babel/runtime': 7.29.2
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)
'@sinclair/typebox@0.27.10': {}
'@sinclair/typebox@0.34.49': {}
@@ -12804,6 +12760,12 @@ snapshots:
dependencies:
'@types/node': 24.12.4
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -16720,6 +16682,11 @@ snapshots:
media-typer@0.3.0: {}
mediabunny@1.49.0:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
-1
View File
@@ -19,7 +19,6 @@ allowBuilds:
'esbuild': true
'unrs-resolver': true
patchedDependencies:
'@discord/bottom-sheet@4.6.1': patches/@discord__bottom-sheet@4.6.1.patch
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
+14 -17
View File
@@ -31,7 +31,6 @@ import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -241,22 +240,20 @@ function App() {
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<LandingProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</LandingProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<LandingProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</LandingProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
+9 -12
View File
@@ -24,7 +24,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
@@ -220,17 +219,15 @@ function App() {
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<LandingProvider>
<InnerApp />
</LandingProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<LandingProvider>
<InnerApp />
</LandingProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
+15
View File
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -53,6 +54,10 @@ export type Events = {
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home'
status: 'granted' | 'denied' | 'undetermined'
}
'notifications:bundleExpand': {
notificationType: NotificationType
authorCount: number
}
'state:background': {
secondsActive: number
}
@@ -464,6 +469,7 @@ export type Events = {
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
| 'GroupChat'
| 'NotificationExpandedProfileCard'
}
'profile:followers:view': {
contextProfileDid: string
@@ -570,6 +576,7 @@ export type Events = {
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
| 'GroupChat'
| 'NotificationExpandedProfileCard'
}
'chat:create': {
logContext:
@@ -586,6 +593,14 @@ export type Events = {
| 'SendViaChatDialog'
| 'ConvoSettings'
}
// Message replies
'chat:message:reply:send': {
convoId: string
isGroup: boolean
}
'chat:message:reply:tap': {
convoId: string
}
// Group chat adoption
'groupchat:create': {
+13 -3
View File
@@ -31,9 +31,10 @@ export function AvatarBubbles({
profiles: allProfiles,
self = false,
size = 120,
count,
}: {
animate?: boolean
profiles: (bsky.profile.AnyProfileView | undefined)[]
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
@@ -42,13 +43,22 @@ export function AvatarBubbles({
*/
self?: boolean
size?: number
/**
* The true number of members, used to decide how many bubbles to render when
* it exceeds the number of `profiles` we have on hand (e.g. an invite preview
* that only carries a few member profiles for a much larger group). Slots
* without a profile render as placeholders. Defaults to `profiles.length`.
*/
count?: number
}) {
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
const bubbleCount = Math.max(profiles.length, count ?? 0)
const scale = size / 120
const marginOffset = size < 120 ? -2 : 0
@@ -79,7 +89,7 @@ export function AvatarBubbles({
}, [animate, p0, p1, p2, p3])
const scales = [p0, p1, p2, p3]
const layouts = getLayouts(profiles.length)
const layouts = getLayouts(bubbleCount)
return (
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
+2 -1
View File
@@ -332,10 +332,10 @@ export function Composer({
contentPaddingStyle,
a.z_20,
{
color: 'transparent',
background: 'transparent',
},
web({
color: 'transparent',
caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none',
scrollbarWidth: 'thin',
@@ -346,6 +346,7 @@ export function Composer({
{...tapper.inputProps}
{...sift.targetProps}
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
rawValue={tapper.state.text}
onBlur={e => {
rest.onBlur?.(e)
setActiveFacet(null)
+16 -7
View File
@@ -241,6 +241,7 @@ export function Trigger({
contentLabel,
style,
onTap,
swipeGesture,
}: TriggerProps) {
const context = useContextMenuContext()
const playHaptic = useHaptics()
@@ -364,12 +365,20 @@ export function Trigger({
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
// Order matters here: doubleTapGesture must come before tapGesture.
const composedGestures = Gesture.Exclusive(
const tapAndHoldGestures = Gesture.Exclusive(
doubleTapGesture,
tapGesture,
pressAndHoldGesture,
)
// An optional swipe gesture (e.g. swipe-to-reply) races against the tap/hold
// group: whichever activates first wins and cancels the rest, so they're
// mutually exclusive. Race (not Exclusive) avoids a held-but-not-yet-moved
// swipe Pan blocking the long-press from firing.
const composedGestures = swipeGesture
? Gesture.Race(swipeGesture, tapAndHoldGestures)
: tapAndHoldGestures
const measurement = context.measurement || pendingMeasurement?.measurement
return (
@@ -850,10 +859,11 @@ export function Item({
!unstyled && [
a.flex_row,
a.align_center,
a.px_2xl,
a.px_lg,
a.gap_sm,
a.rounded_md,
t.atoms.bg_contrast_25,
{gap: 6, minHeight: 44, paddingVertical: 10},
{minHeight: 44, paddingVertical: 10},
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled &&
t.atoms.bg_contrast_50,
@@ -882,8 +892,7 @@ export function ItemText({children, style}: ItemTextProps) {
style={[
a.flex_1,
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
a.font_medium,
style,
destructive && {color: t.palette.negative_500},
disabled && t.atoms.text_contrast_low,
@@ -898,13 +907,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
const {disabled, destructive} = useContextMenuItemContext()
return (
<Comp
size="lg"
size="md"
fill={
disabled
? t.atoms.text_contrast_low.color
: destructive
? t.palette.negative_500
: t.atoms.text_contrast_medium.color
: t.atoms.text.color
}
/>
)
+9
View File
@@ -4,6 +4,7 @@ import {
type StyleProp,
type ViewStyle,
} from 'react-native'
import {type GestureType} from 'react-native-gesture-handler'
import {type SharedValue} from 'react-native-reanimated'
import type * as Dialog from '#/components/Dialog'
@@ -94,6 +95,14 @@ export type TriggerProps = {
* @platform ios, android
*/
onTap?: () => void
/**
* An optional gesture (e.g. swipe-to-reply) composed into the same
* arbitration group as the tap and press-and-hold gestures via `Gesture.Race`,
* making it mutually exclusive with them - only one can win a given touch.
*
* @platform ios, android
*/
swipeGesture?: GestureType
}
export type TriggerChildProps =
| {
+14 -2
View File
@@ -91,6 +91,9 @@ export default function ImageViewRoot({
'use no memo'
const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
// Lives here rather than in ImageView so it survives the remount
// when the orientation-based key below changes on rotation.
const [imageIndex, setImageIndex] = useState(nextLightbox?.index ?? 0)
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
'portrait',
)
@@ -101,6 +104,7 @@ export default function ImageViewRoot({
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
setImageIndex(nextLightbox.index)
}
useEffect(() => {
@@ -193,6 +197,8 @@ export default function ImageViewRoot({
<ImageView
key={activeLightbox.id + '-' + orientation}
lightbox={activeLightbox}
imageIndex={imageIndex}
setImageIndex={setImageIndex}
orientation={orientation}
onRequestClose={onRequestClose}
onPressSave={onPressSave}
@@ -210,6 +216,8 @@ export default function ImageViewRoot({
function ImageView({
lightbox,
imageIndex,
setImageIndex,
orientation,
onRequestClose,
onPressSave,
@@ -220,6 +228,8 @@ function ImageView({
thumbRects,
}: {
lightbox: Lightbox
imageIndex: number
setImageIndex: React.Dispatch<React.SetStateAction<number>>
orientation: 'portrait' | 'landscape'
onRequestClose: () => void
onPressSave: (uri: string) => void
@@ -229,12 +239,14 @@ function ImageView({
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
const {images, index: initialImageIndex, metricsContext} = lightbox
const {images, metricsContext} = lightbox
// Capture at mount: after a rotation remount this is the preserved
// current index, so the pager re-opens on the same image.
const [initialImageIndex] = useState(imageIndex)
const ax = useAnalytics()
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setIsAltExpanded] = useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
-5
View File
@@ -26,7 +26,6 @@ import {
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
@@ -140,7 +139,6 @@ export function useLink({
}
const isExternal = isExternalUrl(href)
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
@@ -188,8 +186,6 @@ export function useLink({
) {
void openLink(href)
} else {
closeModal() // close any active modals
const [screen, params] = router.matchPath(href) as [
screen: keyof AllNavigatorParams,
params?: RouteParams,
@@ -242,7 +238,6 @@ export function useLink({
isExternal,
href,
openLink,
closeModal,
action,
navigation,
overridePresentation,
@@ -1,99 +0,0 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
@@ -58,7 +58,7 @@ export function JoinRequestEmbedBody({
const box = [
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.border_contrast_low,
{height: JOIN_REQUEST_EMBED_HEIGHT},
]
@@ -127,7 +127,6 @@ export const StandardSiteEmbed = ({
style={[
a.flex_col,
a.rounded_lg,
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.bg,
@@ -135,126 +134,149 @@ export const StandardSiteEmbed = ({
preview && a.pointer_events_none,
style,
]}>
{/*
* The article is an in-flow `Link` wrapping the content so the iOS peek
* menu has something to lift. The publication footer below lives outside
* it - it has its own link (to the publication) and Subscribe button, so
* it can't be nested inside the article's peek.
*/}
<Link
peek
shouldProxy
to={view.uri}
label={view.title || l`Open link to ${niceUrl}`}
onPress={onPress}
onLongPress={onLongPress}
style={[a.absolute, a.inset_0, a.z_10]}
style={[a.rounded_lg]}
{...web({
onMouseEnter: onInteract,
onMouseLeave: onInteractOut,
})}
onFocus={onInteract}
onBlur={onInteractOut}>
<></>
</Link>
<View
style={[
a.w_full,
a.z_10,
a.pointer_events_none,
interacted && [t.atoms.bg_contrast_25],
]}>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
<View
style={[
a.flex_1,
a.pt_sm,
hasMedia && a.border_t,
interacted
? t.atoms.border_contrast_high
: t.atoms.border_contrast_low,
{gap: 3},
isStandard && a.pt_md,
]}>
{() => (
<View
style={[
a.pb_xs,
a.px_md,
{gap: 3},
isStandard && [{gap: 5}, a.pb_sm],
a.w_full,
a.overflow_hidden,
// The container can't clip (overflow_hidden there breaks the peek
// lift animation), so each half rounds its own outer corners to
// match the container border. The article rounds the top, plus the
// bottom when there's no footer beneath it.
{
borderTopLeftRadius: a.rounded_lg.borderRadius,
borderTopRightRadius: a.rounded_lg.borderRadius,
},
!view.source && {
borderBottomLeftRadius: a.rounded_lg.borderRadius,
borderBottomRightRadius: a.rounded_lg.borderRadius,
},
interacted ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
isStandard && [a.text_lg, a.font_bold],
]}>
{view.title}
</Text>
{view.description ? (
<Text
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
{imageUri ? (
<Image
style={[a.aspect_card]}
source={{uri: imageUri}}
accessibilityIgnoresInvertColors
loading="lazy"
useAppleWebpCodec
/>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View
style={[
a.flex_1,
a.pt_sm,
hasMedia && a.border_t,
interacted
? t.atoms.border_contrast_high
: t.atoms.border_contrast_low,
{gap: 3},
isStandard && a.pt_md,
]}>
<View
style={[a.flex_row, a.align_center, a.gap_md, {paddingTop: 2}]}>
{view.createdAt && (
style={[
a.pb_xs,
a.px_md,
{gap: 3},
isStandard && [{gap: 5}, a.pb_sm],
]}>
<Text
emoji
numberOfLines={3}
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
isStandard && [a.text_lg, a.font_bold],
]}>
{view.title}
</Text>
{view.description ? (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{niceDate(i18n, view.createdAt, 'long', 'none')}
emoji
numberOfLines={view.thumb ? 2 : 4}
style={[a.text_sm, a.leading_snug]}>
{view.description}
</Text>
)}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_medium} />
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{l({
message: plural(view.readingTime, {
one: '#m',
other: '#m',
}),
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
})}
</Text>
) : undefined}
{isStandard && (view.createdAt || view.readingTime) && (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{paddingTop: 2},
]}>
{view.createdAt && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{niceDate(i18n, view.createdAt, 'long', 'none')}
</Text>
)}
{view.readingTime && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Clock size="xs" style={t.atoms.text_contrast_medium} />
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{l({
message: plural(view.readingTime, {
one: '#m',
other: '#m',
}),
comment: `How long it takes to read an article, in minutes. Displayed in a short form, e.g. "5m" for 5 minutes.`,
})}
</Text>
</View>
)}
</View>
)}
</View>
</View>
{!view.source && (
<View style={[a.px_md]}>
<Divider />
<View style={[a.py_sm]}>
<StandardSiteMetaRow preview={preview} view={view} />
</View>
</View>
)}
</View>
</View>
{!view.source && (
<View style={[a.px_md]}>
<Divider />
<View style={[a.py_sm]}>
<StandardSiteMetaRow preview={preview} view={view} />
</View>
</View>
)}
</View>
</Link>
{view.source && (
<View style={[a.z_20]}>
<View>
<Divider />
<PublicationFooter
preview={preview}
@@ -629,6 +651,14 @@ export function PublicationFooter({
a.justify_between,
a.p_md,
a.gap_md,
a.overflow_hidden,
// Rounds the bottom corners to match the container border, since the
// container itself can't clip (overflow_hidden there breaks the peek
// lift animation on the article above).
{
borderBottomLeftRadius: a.rounded_lg.borderRadius,
borderBottomRightRadius: a.rounded_lg.borderRadius,
},
gtPhone && [a.flex_row, a.gap_sm],
interactedOuter && t.atoms.bg_contrast_25,
preview && a.pointer_events_none,
@@ -553,7 +553,7 @@ function ExistingChatCard({
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.members.length}
value={convo.details.memberCount}
one="# member"
other="# members"
/>
@@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAllListMembersQuery} from '#/state/queries/list-members'
import {
getMembership,
type ListMembersip,
useDangerousListMembershipsQuery,
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
@@ -61,7 +59,7 @@ function DialogInner({
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {data: memberships} = useDangerousListMembershipsQuery()
const {data: listMembers} = useAllListMembersQuery(list.uri)
const renderProfileCard = useCallback(
(item: ProfileItem) => {
@@ -69,13 +67,13 @@ function DialogInner({
<UserResult
profile={item.profile}
onChange={onChange}
memberships={memberships}
listMembers={listMembers}
list={list}
moderationOpts={moderationOpts}
/>
)
},
[onChange, memberships, list, moderationOpts],
[onChange, listMembers, list, moderationOpts],
)
return (
@@ -86,16 +84,30 @@ function DialogInner({
)
}
/**
* Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record)
*/
function getMembership(
listMembers: AppBskyGraphDefs.ListItemView[] | undefined,
actorDid: string,
): string | false | undefined {
if (!listMembers) {
return undefined
}
const member = listMembers.find(item => item.subject.did === actorDid)
return member ? member.uri : false
}
function UserResult({
profile,
list,
memberships,
listMembers,
onChange,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
list: AppBskyGraphDefs.ListView
memberships: ListMembersip[] | undefined
listMembers: AppBskyGraphDefs.ListItemView[] | undefined
onChange?: (
type: 'add' | 'remove',
profile: bsky.profile.AnyProfileView,
@@ -104,8 +116,8 @@ function UserResult({
}) {
const {_} = useLingui()
const membership = useMemo(
() => getMembership(memberships, list.uri, profile.did),
[memberships, list.uri, profile.did],
() => getMembership(listMembers, profile.did),
[listMembers, profile.did],
)
const {mutate: listMembershipAdd, isPending: isAddingPending} =
useListMembershipAddMutation({
@@ -0,0 +1,331 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNetworkError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {
type ListWithMembership,
removeListMembershipOptimistically,
updateListMembershipOptimistically,
useListsWithMembershipQuery,
} from '#/state/queries/lists-with-membership'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export type UserAddRemoveListsDialogProps = {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView | undefined
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}
export function UserAddRemoveListsDialog({
control,
profile,
onAdd,
onRemove,
}: UserAddRemoveListsDialogProps) {
return (
<Dialog.Outer
control={control}
testID="userAddRemoveListsDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<ListsContent profile={profile} onAdd={onAdd} onRemove={onRemove} />
</Dialog.Outer>
)
}
function Empty() {
const t = useTheme()
return (
<View
style={[
a.gap_2xl,
platform({web: {paddingTop: 100}, native: {paddingTop: 64}}),
]}>
<View style={[a.gap_xs, a.align_center]}>
<ListIcon
size="xl"
style={{color: t.atoms.border_contrast_medium.borderColor}}
/>
<Text style={[a.text_center]}>
<Trans>You have no lists.</Trans>
</Text>
</View>
</View>
)
}
function ListsContent({
profile,
onAdd,
onRemove,
}: {
profile: bsky.profile.AnyProfileView | undefined
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) {
const t = useTheme()
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
const {
data,
isError,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useListsWithMembershipQuery({actor: profile?.did})
const listItems = data?.pages.flatMap(page => page.listsWithMembership) || []
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
// Error handling is optional since this is just pagination
}
}
const renderItem = ({item}: {item: ListWithMembership}) =>
profile ? (
<ListItem
listWithMembership={item}
profile={profile}
onAdd={onAdd}
onRemove={onRemove}
/>
) : null
const onClose = () => {
control.close()
}
const listHeader = (
<View
style={[
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
t.atoms.bg,
gtMobile ? a.pt_2xl : a.pt_xl,
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
{profile ? (
<Trans>
Update {createSanitizedDisplayName(profile, true)} in Lists
</Trans>
) : (
<Trans>Update in Lists</Trans>
)}
</Text>
<Button
testID="doneBtn"
label={l`Close`}
onPress={onClose}
variant="ghost"
color="secondary"
size="small"
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
)
return (
<Dialog.InnerFlatList
data={isLoading ? [{}] : listItems}
renderItem={
isLoading
? () => (
<View style={[a.align_center, a.py_2xl]}>
<Loader size="xl" />
</View>
)
: renderItem
}
keyExtractor={
isLoading
? () => 'lists_dialog_loader'
: (item: ListWithMembership) => item.list.uri
}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.1}
stickyHeaderIndices={web([0])}
ListHeaderComponent={listHeader}
ListFooterComponent={
isFetchingNextPage ? (
<View style={[a.align_center, a.py_lg]}>
<Loader size="lg" />
</View>
) : null
}
ListEmptyComponent={!isLoading && data ? <Empty /> : null}
webInnerContentContainerStyle={[a.py_0]}
style={platform({
web: [a.px_2xl, a.pb_md],
native: [a.px_2xl, a.pt_lg],
})}
/>
)
}
function ListItem({
listWithMembership,
profile,
onAdd,
onRemove,
}: {
listWithMembership: ListWithMembership
profile: bsky.profile.AnyProfileView
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}) {
const {t: l} = useLingui()
const t = useTheme()
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const list = listWithMembership.list
const listItem = listWithMembership.listItem
const isMember = !!listItem
const {mutate: addMembership, isPending: isPendingAdd} =
useListMembershipAddMutation({
subject: profile,
onSuccess: data => {
Toast.show(l`Added to list`)
onAdd?.(list.uri)
updateListMembershipOptimistically({
queryClient,
actor: profile.did,
listUri: list.uri,
membershipUri: data.uri,
subject: {
did: profile.did,
handle: profile.handle,
displayName: profile.displayName,
},
})
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to list', {safeMessage: err})
}
Toast.show(l`Failed to add to list`, {type: 'error'})
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(l`Removed from list`)
onRemove?.(list.uri)
removeListMembershipOptimistically({
queryClient,
actor: profile.did,
listUri: list.uri,
})
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from list', {safeMessage: err})
}
Toast.show(l`Failed to remove from list`, {type: 'error'})
},
})
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => {
if (isPending) return
if (!isMember) {
addMembership({
listUri: list.uri,
actorDid: profile.did,
})
} else {
if (!listItem?.uri) {
logger.error('Cannot remove: missing membership URI')
return
}
removeMembership({
listUri: list.uri,
actorDid: profile.did,
membershipUri: listItem.uri,
})
}
}
return (
<View
testID={`toggleBtn-${list.name}`}
style={[a.flex_row, a.align_center, a.py_md, a.gap_md]}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(list.name)}
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
</View>
<Button
testID={`user-${profile.handle}-addBtn`}
label={isMember ? l`Remove` : l`Add`}
onPress={handleToggleMembership}
disabled={isPending}
size="tiny"
color={isMember ? 'secondary' : 'primary_subtle'}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isMember ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
+26 -28
View File
@@ -2,8 +2,9 @@ import {View} from 'react-native'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {SwipeToReply} from '#/components/dms/SwipeToReply'
import type * as bsky from '#/types/bsky'
export function ActionsWrapper({
@@ -14,40 +15,37 @@ export function ActionsWrapper({
children,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: React.ReactNode
}) {
const {t: l} = useLingui()
const {setReply} = useMessageReplies()
return (
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
<View style={[a.flex_1, a.relative]}>
<View
style={[
{maxWidth: '80%'},
isFromSelf
? [a.self_end, a.align_end]
: [a.self_start, a.align_start],
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
</View>
)
}
</MessageContextMenu>
<SwipeToReply isFromSelf={isFromSelf} onReply={() => setReply(message)}>
{swipeGesture => (
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}
swipeGesture={swipeGesture}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
<View
accessible={true}
accessibilityActions={[
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
)
}
</MessageContextMenu>
)}
</SwipeToReply>
)
}
+9 -5
View File
@@ -15,18 +15,20 @@ import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {canReact, hasReachedReactionLimit} from './util'
import {
canReact,
hasReachedReactionLimit,
MESSAGE_BUBBLE_MAX_WIDTH,
} from './util'
export function ActionsWrapper({
message,
hasReactions,
isFromSelf,
senderProfile,
moderationOpts,
children,
}: {
message: ChatBskyConvoDefs.MessageView
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
@@ -113,7 +115,6 @@ export function ActionsWrapper({
isFromSelf
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
: [a.ml_xs, {marginRight: 'auto'}],
hasReactions ? [a.mb_2xl] : undefined,
]}>
{reactionsAvailable && (
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
@@ -166,7 +167,10 @@ export function ActionsWrapper({
</MessageContextMenu>
</View>
<View
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
style={[
{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH},
isFromSelf ? a.align_end : a.align_start,
]}>
{children}
</View>
</View>
+7 -2
View File
@@ -29,7 +29,12 @@ export function Card({size}: {size: 'large' | 'small'}) {
return (
<View style={[a.flex_row, a.gap_md, a.align_center]}>
<AvatarBubbles size={56} self profiles={avatarProfiles} />
<AvatarBubbles
size={56}
self
profiles={avatarProfiles}
count={preview.memberCount}
/>
<View style={[a.flex_1, size === 'large' ? a.gap_2xs : a.gap_xs]}>
<Text
emoji
@@ -57,7 +62,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '}
<Plural
value={preview.memberCount}
value={preview.memberLimit}
one="member"
other="members"
/>
+1 -1
View File
@@ -81,7 +81,7 @@ export function Root({
let action: ChatInviteAction | undefined
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
const convoId = preview.convo?.id
const isFollowing = preview.owner.viewer?.following ?? false
const isFollowing = preview.owner.viewer?.followedBy ?? false
const hasRequested = !convoId && preview.viewer?.requestedAt != null
if (convoId && convoId === currentConvoId) {
+19 -3
View File
@@ -1,5 +1,6 @@
import {memo, useCallback} from 'react'
import {Platform} from 'react-native'
import {type GestureType} from 'react-native-gesture-handler'
import * as Clipboard from 'expo-clipboard'
import {
type ChatBskyConvoDefs,
@@ -20,6 +21,8 @@ import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
@@ -36,17 +39,24 @@ export let MessageContextMenu = ({
senderProfile,
moderationOpts,
children,
swipeGesture,
}: {
message: ChatBskyConvoDefs.MessageView
senderProfile?: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts | undefined
children: TriggerProps['children']
/**
* Native only. A swipe gesture (swipe-to-reply) composed into the trigger's
* gesture group so it's mutually exclusive with the tap and long-press.
*/
swipeGesture?: GestureType
}): React.ReactNode => {
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const convo = useConvoActive()
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const {setReply} = useMessageReplies()
const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate()
@@ -141,7 +151,8 @@ export let MessageContextMenu = ({
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
}: ${message.text}`}
swipeGesture={swipeGesture}>
{children}
</ContextMenu.Trigger>
@@ -151,6 +162,13 @@ export let MessageContextMenu = ({
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<ContextMenu.Item
testID="messageDropdownReplyBtn"
label={l`Reply`}
onPress={() => setReply(message)}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item>
{message.text.length > 0 && (
<>
<ContextMenu.Item
@@ -172,7 +190,6 @@ export let MessageContextMenu = ({
</>
)}
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}>
@@ -181,7 +198,6 @@ export let MessageContextMenu = ({
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => openReportMessage(message, senderProfile)}>
+383 -116
View File
@@ -8,10 +8,15 @@ import {
type ViewStyle,
} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
interpolateColor,
LayoutAnimationConfig,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withTiming,
ZoomIn,
ZoomOut,
@@ -39,11 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {atoms as a, native, platform, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
@@ -53,32 +60,57 @@ 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'
import {
CLUSTERED_MESSAGE_THRESHOLD_MS,
filterBlockedReactions,
MESSAGE_BUBBLE_MAX_WIDTH,
MESSAGE_GAP_THRESHOLD_MS,
} from './util'
const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18
const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 20
function messageIsReply(
message:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null,
): boolean {
return (
ChatBskyConvoDefs.isMessageView(message) &&
(ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo))
)
}
function isWithinClusterBoundary({
isPending,
message,
adjacentMessage,
isFromSameSender,
currentSentAt,
direction,
}: {
isPending: boolean
message: ChatBskyConvoDefs.MessageView
adjacentMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
isFromSameSender: boolean
currentSentAt: string
direction: 'prev' | 'next'
}): boolean {
// A reply always starts its own cluster, breaking grouping with the message
// above it. Looking back, that's a boundary if this message is a reply;
// looking forward, it's a boundary if the next message is a reply.
if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) {
return true
}
if (!isFromSameSender) return true
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
const currentSentAt = message.sentAt
const thisDate = new Date(currentSentAt)
const adjDate = new Date(adjacentMessage.sentAt)
const diff =
@@ -122,6 +154,16 @@ let MessageItem = ({
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const {openReactions} = useMessageDialogs()
const {scrollToMessage, highlightedMessage} = useMessageReplies()
// `replyTo` comes back hydrated as the referenced message (or a deleted-
// message tombstone). Narrow away the open-union fallback so we only render
// shapes we understand.
const replyTo =
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)
? message.replyTo
: undefined
const isPending = item.type === 'pending-message'
@@ -144,17 +186,17 @@ let MessageItem = ({
const isFirstInCluster = isWithinClusterBoundary({
isPending,
message,
adjacentMessage: prevMessage,
isFromSameSender: isPrevFromSameSender,
currentSentAt: message.sentAt,
direction: 'prev',
})
const isLastInCluster = isWithinClusterBoundary({
isPending,
message,
adjacentMessage: nextMessage,
isFromSameSender: isNextFromSameSender,
currentSentAt: message.sentAt,
direction: 'next',
})
@@ -168,9 +210,15 @@ let MessageItem = ({
const isInMiddleOfCluster =
isInCluster && !isFirstInCluster && !isLastInCluster
const hasReactions = message.reactions && message.reactions.length > 0
const visibleReactions = useMemo(
() => filterBlockedReactions(message.reactions, relatedProfiles),
[message.reactions, relatedProfiles],
)
const hasReactions = visibleReactions.length > 0
const prevHasReactions =
prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
prevIsMessage &&
filterBlockedReactions(prevMessage.reactions, relatedProfiles).length > 0
const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text)
const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text)
const squaredBottomCorner =
@@ -186,6 +234,15 @@ let MessageItem = ({
const pendingColor = t.palette.primary_300
const bubbleColor = isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const rt = new RichTextAPI({text: message.text, facets: message.facets})
const hasEmbed =
@@ -214,6 +271,30 @@ let MessageItem = ({
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
}, [targetTopRadius, topRadiusSV])
// Flash the message background when it's been scrolled to (e.g. by tapping a
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
// so re-tapping the same message re-triggers the flash.
const highlightSV = useSharedValue(0)
const isHighlighted = highlightedMessage?.id === message.id
const highlightKey = isHighlighted ? highlightedMessage.key : null
useEffect(() => {
if (highlightKey === null) return
highlightSV.set(
withSequence(
withTiming(1, {duration: 150}),
withDelay(400, withTiming(0, {duration: 450})),
),
)
}, [highlightKey, highlightSV])
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[bubbleColor, highlightColor],
),
}))
const borderRadiusStyle = useAnimatedStyle(() =>
isFromSelf
? {
@@ -240,11 +321,11 @@ let MessageItem = ({
)
const groupedReactions = useMemo(
() => groupReactions(message.reactions),
[message.reactions],
() => groupReactions(visibleReactions),
[visibleReactions],
)
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
const reactions = visibleReactions
const hasSelfReacted = reactions.some(
r => r.sender.did === currentAccount?.did,
@@ -275,12 +356,15 @@ let MessageItem = ({
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{hasReactions ? (
<View
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={[
a.relative,
a.bottom_0,
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
a.absolute,
{top: '100%'},
isFromSelf ? [a.right_0] : [a.left_0, isGroupChat && a.ml_sm],
a.px_sm,
a.z_10,
]}>
<Pressable
accessible={true}
@@ -292,24 +376,22 @@ let MessageItem = ({
a.flex_row,
a.gap_2xs,
isFromSelf ? a.justify_end : a.justify_start,
a.flex_wrap,
a.rounded_lg,
a.border,
t.atoms.border_contrast_low,
t.atoms.shadow_xs,
a.px_sm,
hasSelfReacted
? {backgroundColor: t.palette.primary_100}
: t.atoms.bg_contrast_25,
{
paddingTop: platform({android: 2, default: 3}),
paddingBottom: platform({android: 2, default: 3}),
paddingLeft: 6,
paddingRight: 6,
transform: [{translateY: -8}],
transform: [{translateY: -6}],
},
]}
onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => (
{groupedReactions.slice(0, 10).map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={
@@ -330,7 +412,8 @@ let MessageItem = ({
</Text>
</Animated.View>
))}
{groupedReactions.length !== reactions.length &&
{(groupedReactions.length !== reactions.length ||
groupedReactions.length > 10) &&
reactions.length > 1 ? (
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
<Text
@@ -347,7 +430,7 @@ let MessageItem = ({
</View>
) : null}
</Pressable>
</View>
</Animated.View>
) : null}
</LayoutAnimationConfig>
)
@@ -365,34 +448,28 @@ let MessageItem = ({
style={[
messageInset,
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
hasReactions && {paddingBottom: 26},
]}>
<View style={[a.relative]}>
{showAvatar ? (
<View
style={[
a.absolute,
a.bottom_0,
a.z_50,
hasReactions && {
transform: [
{
translateY: platform({
ios: -29,
default: -27,
}),
},
],
},
]}>
{avatar}
</View>
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
) : null}
<View
style={[
a.relative,
a.flex_grow,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}>
{displayName && showDisplayName ? (
{replyTo ? (
<ReplyCaption
replyTo={replyTo}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
replierDisplayName={displayName}
relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)}
/>
) : displayName && showDisplayName ? (
<Text
style={[
a.text_xs,
@@ -408,81 +485,93 @@ let MessageItem = ({
{profile && isBlockedOrBlocking(profile) && isGroupChat ? (
<BlockedPlaceholder profile={profile} style={borderRadiusStyle} />
) : (
<ActionsWrapper
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
senderProfile={profile}
moderationOpts={moderationOpts}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
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`}
style={[
!isFromSelf && isGroupChat && a.ml_sm,
!isOnlyEmoji(message.text) && [
a.rounded_xl,
a.py_sm,
a.px_md,
{
marginTop: hasEmbedAndText
? CLUSTERED_MESSAGE_GAP
: 0,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
},
isFromSelf ? a.self_end : a.self_start,
borderRadiusStyle,
],
]}>
<RichText
value={rt}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
// Emoji-only: add top leading to avoid clipping the
// glyph, then pull the bottom up by the same amount so
// the glyph bottom-aligns with the avatar instead of
// sitting above its line-box baseline.
isOnlyEmoji(message.text) && [
a.leading_tight,
// Visually align bottom of the emoji with the avatar
!isFromSelf &&
platform({
android: {marginTop: a.mt_2xs.marginTop},
default: {marginBottom: -a.mb_sm.marginBottom},
}),
],
]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
<View style={[a.relative]}>
<ActionsWrapper
isFromSelf={isFromSelf}
message={message}
senderProfile={profile}
moderationOpts={moderationOpts}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
squaredBottomCorner={
squaredBottomCorner || hasEmbedAndText
}
squaredTopCorner={squaredTopCorner}
highlightSV={highlightSV}
/>
</Animated.View>
)}
)}
{ChatBskyEmbedJoinLink.isView(message.embed) && (
<MessageItemInviteEmbed
embed={message.embed}
isFromSelf={isFromSelf}
isGroupChat={isGroupChat}
squaredBottomCorner={
squaredBottomCorner || hasEmbedAndText
}
squaredTopCorner={squaredTopCorner}
highlightSV={highlightSV}
/>
)}
{rt.text.length > 0 && (
<Animated.View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[
!isFromSelf && isGroupChat && a.ml_sm,
!isOnlyEmoji(message.text) && [
a.rounded_xl,
a.py_sm,
a.px_md,
a.max_w_full,
{
marginTop: hasEmbedAndText
? CLUSTERED_MESSAGE_GAP
: 0,
},
isFromSelf ? a.self_end : a.self_start,
borderRadiusStyle,
highlightStyle,
],
]}>
{replyTo && !isOnlyEmoji(message.text) ? (
<ReplyQuote
replyTo={replyTo}
isFromSelf={isFromSelf}
relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)}
/>
) : null}
<RichText
value={rt}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
// Emoji-only: add top leading to avoid clipping the
// glyph, then pull the bottom up by the same amount so
// the glyph bottom-aligns with the avatar instead of
// sitting above its line-box baseline.
isOnlyEmoji(message.text) && [
a.leading_tight,
// Visually align bottom of the emoji with the avatar
!isFromSelf &&
platform({
android: {marginTop: a.mt_2xs.marginTop},
default: {marginBottom: -a.mb_sm.marginBottom},
}),
],
]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
shouldProxyLinks={true}
/>
</Animated.View>
)}
</ActionsWrapper>
{appliedReactions}
</ActionsWrapper>
</View>
)}
</View>
</View>
@@ -563,7 +652,7 @@ function BlockedPlaceholder({
return (
<>
<Button
style={[{maxWidth: '80%'}, a.self_start]}
style={[{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH}, a.self_start]}
label={
profile.viewer?.blocking
? l`This message is hidden because you are blocking this user.`
@@ -644,3 +733,181 @@ function BlockedPlaceholder({
</>
)
}
/**
* The "↪ X replied to Y" caption rendered above a reply message, in place of
* the display name. `X` is the person sending the reply (self -> "you"), `Y` is
* the original sender. Tapping it scrolls to the original (if loaded).
*
* Aligns with the sender's display name for others (left), or with the message
* bubble for self (right).
*/
function ReplyCaption({
replyTo,
isFromSelf,
isGroupChat,
replierDisplayName,
relatedProfiles,
onPress,
}: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
isFromSelf: boolean
isGroupChat: boolean
replierDisplayName: string | null
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
const originalProfile = relatedProfiles.get(replyTo.sender.did)
const originalName = originalSenderIsSelf
? null
: originalProfile
? createSanitizedDisplayName(originalProfile)
: null
return (
<Button
label={l`Scroll to the message this is replying to`}
onPress={onPress}
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_2xs,
a.pb_2xs,
a.pt_xs,
isFromSelf
? [a.justify_end, a.pr_md]
: [
a.justify_start,
isGroupChat ? {paddingLeft: DISPLAY_NAME_INSET} : a.pl_md,
],
]}>
<ArrowCornerDownRightIcon
size="xs"
style={t.atoms.text_contrast_medium}
/>
<Text
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1}
emoji>
{isFromSelf ? (
originalSenderIsSelf ? (
<Trans>You replied to yourself</Trans>
) : originalName ? (
<Trans>You replied to {originalName}</Trans>
) : (
<Trans>You replied</Trans>
)
) : originalSenderIsSelf ? (
<Trans>{replierDisplayName} replied to you</Trans>
) : originalName ? (
<Trans>
{replierDisplayName} replied to {originalName}
</Trans>
) : (
<Trans>{replierDisplayName} replied</Trans>
)}
</Text>
</Button>
)
}
/**
* The nested quote of the original message, rendered at the top of a reply
* bubble. Tapping it scrolls to the original (if loaded).
*/
function ReplyQuote({
replyTo,
isFromSelf,
relatedProfiles,
onPress,
}: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
isFromSelf: boolean
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did),
)
// Hide the quoted content if we block, or are blocked by, the original
// sender - mirroring how the message bubble itself is hidden.
const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false
const senderName =
senderProfile && !isBlocked
? createSanitizedDisplayName(senderProfile)
: null
const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color
const subtleColor = isFromSelf
? t.palette.white
: t.atoms.text_contrast_high.color
const borderColor = isFromSelf
? utils.alpha(t.palette.white, 0.5)
: t.atoms.border_contrast_high.borderColor
let text: string
let subtle = false
if (isBlocked) {
text = l`Blocked message hidden`
subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
text = replyTo.text
if (!text.trim()) {
subtle = true
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
text = l`(chat invite link)`
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
text = l`(contains embedded content)`
} else {
text = l`No text`
}
}
} else {
text = l`Deleted message`
subtle = true
}
return (
<Button
label={
senderName
? l`Replied-to message from ${senderName}, tap to scroll to it`
: l`Replied-to message, tap to scroll to it`
}
onPress={onPress}
style={[
a.mb_xs,
a.rounded_md,
a.p_sm,
a.flex_col,
a.align_start,
a.border,
{borderColor, marginHorizontal: -4},
]}>
{senderName ? (
<Text style={[a.text_xs, {color: subtleColor}]} emoji numberOfLines={1}>
{senderName}
</Text>
) : null}
<Text
style={[
a.text_sm,
{color: subtle ? subtleColor : tintColor},
subtle && a.italic,
]}
emoji
numberOfLines={2}>
{text}
</Text>
</Button>
)
}
+42 -26
View File
@@ -1,5 +1,10 @@
import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {
interpolateColor,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {atoms as a, native, useTheme, web} from '#/alf'
@@ -15,20 +20,53 @@ let MessageItemEmbed = ({
isGroupChat,
squaredTopCorner,
squaredBottomCorner,
highlightSV,
}: {
embed: $Typed<AppBskyEmbedRecord.View>
isFromSelf: boolean
isGroupChat: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
highlightSV: SharedValue<number>
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
const restingColor = isFromSelf ? t.palette.primary_50 : t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[restingColor, highlightColor],
),
}))
const radiiStyle = isFromSelf
? {
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
return (
<MessageContextProvider>
<View
style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
@@ -43,37 +81,15 @@ let MessageItemEmbed = ({
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
{marginTop: -a.mt_sm.marginTop},
]}>
<View>
<Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.ChatMessage}
style={[
a.rounded_xl,
a.overflow_hidden,
a.border_0,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopRightRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
borderTopLeftRadius: squaredTopCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
},
]}
style={[a.rounded_xl, a.overflow_hidden, a.border_0, radiiStyle]}
/>
</View>
</Animated.View>
</View>
</MessageContextProvider>
)
+23 -4
View File
@@ -1,5 +1,10 @@
import {memo} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {
interpolateColor,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
@@ -17,17 +22,31 @@ let MessageItemInviteEmbed = ({
isGroupChat,
squaredTopCorner,
squaredBottomCorner,
highlightSV,
}: {
embed: $Typed<ChatBskyEmbedJoinLink.View>
isFromSelf: boolean
isGroupChat: boolean
squaredTopCorner: boolean
squaredBottomCorner: boolean
highlightSV: SharedValue<number>
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
const convo = useConvoActive()
const restingColor = isFromSelf ? t.palette.primary_50 : t.palette.contrast_50
const highlightColor = isFromSelf
? t.palette.primary_300
: t.palette.primary_100
const highlightStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
highlightSV.get(),
[0, 1],
[restingColor, highlightColor],
),
}))
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
? embed.joinLinkPreview.code
: undefined
@@ -37,6 +56,7 @@ let MessageItemInviteEmbed = ({
<MessageContextProvider>
<View
style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
@@ -48,14 +68,13 @@ let MessageItemInviteEmbed = ({
maxWidth: 360,
}),
]}>
<View
<Animated.View
style={[
a.p_md,
a.gap_md,
a.overflow_hidden,
isFromSelf
? {
backgroundColor: t.palette.primary_50,
borderBottomRightRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
@@ -66,7 +85,6 @@ let MessageItemInviteEmbed = ({
borderTopLeftRadius: BORDER_RADIUS,
}
: {
backgroundColor: t.palette.contrast_50,
borderBottomLeftRadius: squaredBottomCorner
? SQUARED_BORDER_RADIUS
: BORDER_RADIUS,
@@ -76,6 +94,7 @@ let MessageItemInviteEmbed = ({
borderBottomRightRadius: BORDER_RADIUS,
borderTopRightRadius: BORDER_RADIUS,
},
highlightStyle,
]}>
<ChatInvite.Root
code={code}
@@ -84,7 +103,7 @@ let MessageItemInviteEmbed = ({
hasFixedHeight={false}>
<MessageItemInviteEmbedBody />
</ChatInvite.Root>
</View>
</Animated.View>
</View>
</MessageContextProvider>
)
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {type ChatBskyConvoDefs} from '@atproto/api'
/**
* How long a message stays highlighted after scrolling to it, before the flash
* fades out.
*/
export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500
type HighlightedMessage = {
id: string
/**
* Bumped on every highlight so that re-tapping the same reply re-triggers the
* flash even while the previous highlight is still active.
*/
key: number
}
type MessageRepliesContextType = {
/**
* The message currently staged for reply in the composer, or null.
*/
replyTo: ChatBskyConvoDefs.MessageView | null
setReply: (message: ChatBskyConvoDefs.MessageView) => void
clearReply: () => void
/**
* Scroll the list to a message, if it's currently loaded, and flash it. No-op
* otherwise.
*/
scrollToMessage: (messageId: string) => void
/**
* The message to flash, or null. Consumers compare against their own id.
*/
highlightedMessage: HighlightedMessage | null
}
const Context = createContext<MessageRepliesContextType | null>(null)
export function useMessageReplies() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error(
'useMessageReplies must be used within a MessageRepliesProvider',
)
}
return ctx
}
export function MessageRepliesProvider({
children,
scrollToMessage: scrollToMessageRaw,
}: {
children: React.ReactNode
/**
* Performs the actual scroll. Returns true if the message was found and
* scrolled to, false if it isn't currently loaded (so we know whether to
* flash it).
*/
scrollToMessage: (messageId: string) => boolean
}) {
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
null,
)
const [highlightedMessage, setHighlightedMessage] =
useState<HighlightedMessage | null>(null)
const highlightKey = useRef(0)
const clearHighlightTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
setReplyTo(message)
}, [])
const clearReply = useCallback(() => {
setReplyTo(null)
}, [])
const scrollToMessage = useCallback(
(messageId: string) => {
const didScroll = scrollToMessageRaw(messageId)
if (!didScroll) return
highlightKey.current += 1
setHighlightedMessage({id: messageId, key: highlightKey.current})
if (clearHighlightTimeout.current) {
clearTimeout(clearHighlightTimeout.current)
}
clearHighlightTimeout.current = setTimeout(() => {
setHighlightedMessage(null)
}, MESSAGE_HIGHLIGHT_DURATION_MS)
},
[scrollToMessageRaw],
)
useEffect(() => {
return () => {
if (clearHighlightTimeout.current) {
clearTimeout(clearHighlightTimeout.current)
}
}
}, [])
const ctx = useMemo<MessageRepliesContextType>(
() => ({
replyTo,
setReply,
clearReply,
scrollToMessage,
highlightedMessage,
}),
[replyTo, setReply, clearReply, scrollToMessage, highlightedMessage],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+3 -3
View File
@@ -20,10 +20,10 @@ import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
import {IS_LIQUID_GLASS} from '#/env'
import {type ConvoWithDetails} from './util'
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
const PFP_SIZE = 40
export function MessagesListHeader({convo}: {convo?: ConvoWithDetails | null}) {
const t = useTheme()
@@ -176,7 +176,7 @@ function GroupHeaderReady({
},
}
}>
<AvatarBubbles size={40} profiles={convo.members} />
<AvatarBubbles size={PFP_SIZE} profiles={convo.members} />
<View style={[a.flex_row, a.flex_1, a.align_center]}>
<Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
+9 -5
View File
@@ -19,6 +19,7 @@ import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {filterBlockedReactions} from '#/components/dms/util'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -52,10 +53,13 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all')
const reactions = message.reactions
const reactions = useMemo(
() => filterBlockedReactions(message.reactions, relatedProfiles),
[message.reactions, relatedProfiles],
)
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter(
const filteredReactions = reactions.filter(
r => selected === 'all' || r.value === selected,
)
@@ -69,7 +73,7 @@ export function ReactionsDialog({
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
totalReactions={reactions.length}
onFilter={setSelected}
/>
<Dialog.Close />
@@ -96,7 +100,7 @@ export function ReactionsDialog({
header={IS_WEB ? header : null}
style={[web({maxWidth: 400})]}>
{filteredReactions
?.sort((a, b) => {
.sort((a, b) => {
if (a.sender.did === currentAccount?.did) return -1
if (b.sender.did === currentAccount?.did) return 1
return 0
@@ -113,7 +117,7 @@ export function ReactionsDialog({
message={message}
profile={sender}
reaction={reaction}
allReactions={reactions ?? []}
allReactions={reactions}
selected={selected}
setSelected={setSelected}
/>
+196
View File
@@ -0,0 +1,196 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Gesture, type GestureType} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
import {MESSAGE_BUBBLE_MAX_WIDTH} from './util'
// Distance the bubble must travel before releasing fires a reply.
const ACTIVATION_THRESHOLD = 56
// Past the activation point the bubble keeps moving but with exponentially
// increasing resistance, asymptotically approaching this much extra travel.
const MAX_OVERSHOOT = 32
const ICON_DIAMETER = 32
// Absurdly high value so the gesture doesn't arm on the vertical axis and
// hijack list scrolling. reanimated/RNGH don't offer clean per-axis disabling.
const EFFECTIVELY_DISABLED_OFFSET = 200
// 1:1 up to `threshold`, then exponentially diminishing travel: the marginal
// movement decays the further you pull, easing toward a soft cap of
// `threshold + MAX_OVERSHOOT` rather than hitting a hard wall.
function applyResistance(value: number) {
'worklet'
const abs = Math.abs(value)
if (abs <= ACTIVATION_THRESHOLD) return value
const sign = value < 0 ? -1 : 1
const excess = abs - ACTIVATION_THRESHOLD
return (
sign *
(ACTIVATION_THRESHOLD +
MAX_OVERSHOOT * (1 - Math.exp(-excess / MAX_OVERSHOOT)))
)
}
/**
* Swipe a message bubble inward to reply, Signal/WhatsApp style. Self messages
* (right-aligned) swipe left; others' messages (left-aligned) swipe right. A
* reply icon sits stationary in the gutter on the bubble's resting edge and is
* revealed (fade + scale) as the bubble slides away from it. Crossing the
* activation threshold fires a haptic; releasing past it triggers `onReply`.
*
* The pan gesture is built here but handed to `children` via a render prop so
* the consumer can compose it into the message's context-menu gestures with
* `Gesture.Exclusive`. Sharing one arbitration group is what makes the swipe
* and the long-press mutually exclusive - only one can ever win the touch.
*/
export function SwipeToReply({
isFromSelf,
onReply,
enabled = true,
children,
}: {
isFromSelf: boolean
onReply: () => void
enabled?: boolean
children: (swipeGesture: GestureType) => React.ReactNode
}) {
const t = useTheme()
const playHaptic = useHaptics()
const isReducedMotion = useReducedMotion()
const transX = useSharedValue(0)
const hit = useSharedValue(false)
const iconScale = useSharedValue(1)
const swipeGesture = useMemo(() => {
const runPop = () => {
'worklet'
if (isReducedMotion) return
iconScale.set(() =>
withSequence(
withTiming(1.2, {duration: 175}),
withTiming(1, {duration: 100}),
),
)
}
return (
Gesture.Pan()
.enabled(enabled)
// Arm only on the inward axis; the outward direction is effectively
// disabled so the bubble can't be dragged off its own edge.
.activeOffsetX(
isFromSelf
? [-10, EFFECTIVELY_DISABLED_OFFSET]
: [-EFFECTIVELY_DISABLED_OFFSET, 10],
)
.activeOffsetY([
-EFFECTIVELY_DISABLED_OFFSET,
EFFECTIVELY_DISABLED_OFFSET,
])
.onChange(e => {
'worklet'
const dir = isFromSelf
? Math.min(e.translationX, 0)
: Math.max(e.translationX, 0)
transX.set(applyResistance(dir))
const pastThreshold = Math.abs(transX.get()) >= ACTIVATION_THRESHOLD
if (pastThreshold && !hit.get()) {
hit.set(true)
runPop()
runOnJS(playHaptic)('Medium')
} else if (!pastThreshold && hit.get()) {
hit.set(false)
}
})
.onEnd(() => {
'worklet'
// Only a clean end (finger lifted past threshold) triggers the reply.
if (hit.get()) {
runOnJS(onReply)()
}
})
.onFinalize(() => {
'worklet'
// Runs on both end and cancellation, so the bubble always animates
// home even if the gesture is interrupted mid-swipe.
transX.set(withTiming(0, {duration: 200}))
hit.set(false)
})
)
}, [
isFromSelf,
enabled,
onReply,
playHaptic,
isReducedMotion,
transX,
hit,
iconScale,
])
const contentStyle = useAnimatedStyle(() => ({
transform: [{translateX: transX.get()}],
}))
const iconStyle = useAnimatedStyle(() => {
const progress = clamp(Math.abs(transX.get()) / ACTIVATION_THRESHOLD, 0, 1)
return {
opacity: progress,
transform: [
{scale: iconScale.get() * interpolate(progress, [0, 1], [0.5, 1])},
],
}
})
return (
<View style={[a.flex_1, a.relative]}>
<Animated.View
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
{top: 0, bottom: 0},
// Centred roughly half the activation threshold in from the bubble's
// resting edge, so it lands in the gap the bubble reveals.
isFromSelf ? {right: tokens.space.md} : {left: tokens.space.md},
iconStyle,
]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_50,
{width: ICON_DIAMETER, height: ICON_DIAMETER},
]}>
<ReplyIcon size="sm" style={t.atoms.text_contrast_medium} />
</View>
</Animated.View>
{/* Sized + aligned to the bubble so the consumer's GestureDetector (the
context menu's, which composes `swipeGesture`) covers only the bubble,
leaving the gutter free for the back/scroll gestures. */}
<Animated.View
style={[
{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH},
isFromSelf ? a.self_end : a.self_start,
contentStyle,
]}>
{children(swipeGesture)}
</Animated.View>
</View>
)
}
@@ -48,7 +48,7 @@ export function GroupChatProfileCard({
size={44}
disabledPreview
/>
<View>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
+33 -1
View File
@@ -1,5 +1,6 @@
import {
AppBskyEmbedRecord,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
} from '@atproto/api'
@@ -13,6 +14,7 @@ import {
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import type * as bsky from '#/types/bsky'
export type UserMessageInfo = {
message: string | null
@@ -21,13 +23,39 @@ export type UserMessageInfo = {
isBlockedMessage: boolean
}
/**
* Resolves whether the given did is blocked (in either direction) within a
* convo. Prefers the passed-in shadowed `primaryProfile` so optimistic blocks
* reflect immediately, before the convo list refetches - the raw `members`
* fetched with the convo are invisible to the profile shadow cache. Group
* members other than the owner fall back to the raw (potentially stale) member.
*/
export function isDidBlockedInConvo({
did,
members,
primaryProfile,
}: {
did: string | undefined
members: ChatBskyActorDefs.ProfileViewBasic[]
primaryProfile?: bsky.profile.AnyProfileView
}): boolean {
if (!did) return false
if (primaryProfile && primaryProfile.did === did) {
return isBlockedOrBlocking(primaryProfile)
}
const member = members.find(m => m.did === did)
return member ? isBlockedOrBlocking(member) : false
}
export function getMessageInfo({
convo,
currentAccountDid,
primaryProfile,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
primaryProfile?: bsky.profile.AnyProfileView
i18n: I18n
}): UserMessageInfo | null {
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
@@ -42,7 +70,11 @@ export function getMessageInfo({
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
const isBlockedMessage = isDidBlockedInConvo({
did: senderDid,
members: convo.members,
primaryProfile,
})
const prefix = (message: string) => {
if (isFromMe) {
+21
View File
@@ -3,19 +3,24 @@ import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {isDidBlockedInConvo} from '#/components/dms/getMessageInfo'
import type * as bsky from '#/types/bsky'
export type UserReactionInfo = {
message: string
createdAt: string
isBlocked: boolean
}
export function getReactionInfo({
convo,
currentAccountDid,
primaryProfile,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
primaryProfile?: bsky.profile.AnyProfileView
i18n: I18n
}): UserReactionInfo | null {
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
@@ -28,6 +33,21 @@ export function getReactionInfo({
const sender = convo.members.find(m => m.did === senderDid)
const name = sender ? createSanitizedDisplayName(sender) : null
// Hide the preview when either the reactor or the author of the reacted-to
// message is blocked - otherwise a blocked reactor's name or a blocked
// sender's message text would leak into the chat list.
const isBlocked =
isDidBlockedInConvo({
did: senderDid,
members: convo.members,
primaryProfile,
}) ||
isDidBlockedInConvo({
did: reactedTo.sender?.did,
members: convo.members,
primaryProfile,
})
const lastMessageText = reactedTo.text
const fallbackMessage = i18n._(
msg({
@@ -50,5 +70,6 @@ export function getReactionInfo({
return {
message,
createdAt: reaction.createdAt,
isBlocked,
}
}
+26
View File
@@ -7,15 +7,22 @@ import {
} from '@atproto/api'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {platform} from '#/alf'
import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
export const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
export const MESSAGE_BUBBLE_MAX_WIDTH = platform({
web: '80%' as const,
default: '85%' as const,
})
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
switch (profile.associated?.chat?.allowIncoming) {
case 'none':
@@ -86,6 +93,25 @@ export function hasAlreadyReacted(
)
}
/**
* Drops reactions from accounts the viewer is blocking or blocked by, so a
* blocked reactor's identity is never surfaced via the reaction pills or the
* reactions dialog. `relatedProfiles` is shadow-synced by the convo agent, so
* this reflects optimistic blocks. Reactions whose sender isn't in
* `relatedProfiles` are kept - we can't determine their block status, and they
* already render anonymously ("Someone reacted").
*/
export function filterBlockedReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>,
): ChatBskyConvoDefs.ReactionView[] {
if (!reactions) return []
return reactions.filter(reaction => {
const profile = relatedProfiles.get(reaction.sender.did)
return !profile || !isBlockedOrBlocking(profile)
})
}
export function hasReachedReactionLimit(
message: ChatBskyConvoDefs.MessageView,
myDid: string | undefined,
+29 -1
View File
@@ -1,4 +1,4 @@
import {useMemo, useRef, useState} from 'react'
import {useEffect, useMemo, useRef, useState} from 'react'
import {
TextInput,
type TextInputContentSizeChangeEvent,
@@ -16,6 +16,17 @@ export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
/**
* In some cases, like on native, we may not pass in an actual `value` prop.
* This prop allows Android to know if the field was cleared and thereby
* reset its height. This is a small hack required because we need to
* explicitly set the `{height: nativeHeight}` on Android.
*
* If you notice height calcuation issues after clearing the field on
* Android, check if this value is being populated and cleared properly in
* the parent component.
*/
rawValue?: string
}
export function AutosizedTextarea({
@@ -24,6 +35,7 @@ export function AutosizedTextarea({
minRows = 1,
maxRows,
onUpdateHeight,
rawValue,
onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter,
@@ -124,6 +136,22 @@ export function AutosizedTextarea({
onContentSizeChangeOuter?.(e)
}
/*
* Manual height clearing required for Android because we're forced to set
* `{height: nativeHeight}` on Android, and even though `onContentSizeChange`
* fires, the height matches the existing height and we aren't able to reset.
*/
const prevRawValue = useRef(rawValue || '')
useEffect(() => {
if (!IS_ANDROID) return // everything else is fine
if (rawValue === undefined) return // uncontrolled
if (prevRawValue.current?.length && rawValue === '') {
setNativeHeight(minInputHeight)
onUpdateHeight?.(minInputHeight)
}
prevRawValue.current = rawValue
}, [rawValue, minInputHeight])
return (
<TextInput
multiline
@@ -67,7 +67,6 @@ export function DateField({
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
{open && (
// Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor
// Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871
@@ -5,3 +5,9 @@ export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG(
path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z',
},
)
export const ArrowCornerDownRight_Stroke2_Corner3_Rounded = createSinglePathSVG(
{
path: 'M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z',
},
)
+29 -13
View File
@@ -6,7 +6,7 @@ import {
ChatBskyGroupWithdrawJoinRequest,
moderateProfile,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -20,6 +20,7 @@ import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
invalidateJoinLinkPreviewsForCode,
setJoinLinkPreviewRequestedForCode,
useJoinLinkPreviewsQuery,
} from '#/state/queries/join-links'
import {useRequestJoinGroupChat} from '#/state/queries/messages/request-join-group-chat'
@@ -104,9 +105,14 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {mutate: joinGroupChat, isPending: isJoinPending} =
useRequestJoinGroupChat({
onSuccess: data => {
if (code) void invalidateJoinLinkPreviewsForCode(queryClient, code)
switch (data.status) {
case 'pending':
// Optimistically mark the link as requested so any invite cards
// backed by the preview cache (e.g. the DM embed) flip to
// "Requested" right away, rather than waiting on a server refetch
// that can lag behind the write.
if (code)
setJoinLinkPreviewRequestedForCode(queryClient, code, true)
ax.metric('groupchat:inviteLink:redeem', {})
control.close(() => {
Toast.show(
@@ -116,6 +122,10 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
break
case 'joined': {
if (data.convo && data.convo.id) {
// Refetch so invite cards pick up the resolved convo and switch
// their action to "Open chat".
if (code)
void invalidateJoinLinkPreviewsForCode(queryClient, code)
ax.metric('groupchat:inviteLink:redeem', {})
control.close(() => {
Toast.show(l`Successfully joined the group chat!`)
@@ -172,6 +182,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
useWithdrawJoinGroupChatRequest({
onSuccess: () => {
// Optimistically clear the requested state so invite cards backed by
// the preview cache flip back to "Request to join" right away.
if (code) setJoinLinkPreviewRequestedForCode(queryClient, code, false)
control.close(() => {
Toast.show(l`Join request rescinded.`)
})
@@ -249,7 +262,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.text_lg,
a.font_medium,
t.atoms.text_contrast_high,
]}>
@@ -271,9 +284,10 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
)
}
const convoId = joinLinkPreview.convo?.id
const isFollowing = joinLinkPreview.owner.viewer?.following ?? false
const hasRequested = !convoId && joinLinkPreview.viewer?.requestedAt != null
const convoId = joinLinkPreview.convoId
const isFollowing = joinLinkPreview.owner.viewer?.followedBy ?? false
const hasRequested =
!joinLinkPreview.convo && joinLinkPreview.viewer?.requestedAt != null
let canJoin = true
let ButtonIconImage = isJoinPending || isWithdrawPending ? Loader : JoinIcon
@@ -301,10 +315,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
<>
<View style={[a.w_full, a.py_lg, a.align_center]}>
<AvatarBubbles
profiles={[
joinLinkPreview.owner,
...Array(joinLinkPreview.memberCount - 1).fill(undefined),
]}
profiles={[joinLinkPreview.owner]}
count={joinLinkPreview.memberCount}
self
size={135}
/>
@@ -326,9 +338,13 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</View>
<View style={[a.flex_row, a.align_center]}>
<Text style={[a.text_center, a.text_xs, a.leading_snug]}>
<Trans comment="The number of active group chat members out of the total number allowed.">
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{joinLinkPreview.memberCount}/{joinLinkPreview.memberLimit}{' '}
members
<Plural
value={joinLinkPreview.memberLimit}
one="member"
other="members"
/>
</Trans>
</Text>
<View style={[a.flex_row, a.ml_md]}>
@@ -417,7 +433,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</View>
</View>
</View>
{convoId ? (
{joinLinkPreview.convo ? (
<Button
testID="openButton"
onPress={() => {
+5 -5
View File
@@ -244,7 +244,8 @@ function BlockDialogInner({
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.5}
style={[web([{maxWidth: 420}])]}
style={[web([{height: '100vh', maxHeight: 600}])]}
webInnerStyle={[{maxWidth: 420}]}
/>
)
}
@@ -340,11 +341,10 @@ function MutualGroupChat({
a.py_sm,
native(a.px_2xl),
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<AvatarBubbles profiles={convo.members} size={40} />
<View>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug, t.atoms.text]}>
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_semi_bold]} emoji numberOfLines={1}>
{convo.details.name}
</Text>
{isViewerOwner ? (
@@ -1,4 +1,4 @@
import {Suspense, useRef, useState} from 'react'
import {Suspense, useRef} from 'react'
import {Pressable, View} from 'react-native'
import type ViewShot from 'react-native-view-shot'
import {setStringAsync} from 'expo-clipboard'
@@ -25,6 +25,7 @@ import {IS_NATIVE} from '#/env'
import {ActionButtons} from './components/ActionButtons'
import {ThemedQrCard} from './components/ThemedQrCard'
import {ThemePicker} from './components/ThemePicker'
import {useInviteThemeKey} from './hooks/useInviteThemeKey'
import {getInviteTheme, type InviteThemeKey} from './themes'
import {getInviteDisplayUrl, getInviteShareUrl} from './urls'
@@ -39,7 +40,7 @@ export function InviteFriendsDialogInner({
const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession()
const profileQuery = useProfileQuery({did: currentAccount?.did})
const [themeKey, setThemeKey] = useState<InviteThemeKey>('day')
const [themeKey, setThemeKey] = useInviteThemeKey()
const captureRef = useRef<ViewShot>(null)
const theme = getInviteTheme(themeKey)
@@ -0,0 +1,14 @@
import {device, useStorage} from '#/storage'
import {INVITE_THEMES, type InviteThemeKey} from '../themes'
/**
* Persisted color theme for the Invite Friends QR card (APP-2409). Falls back
* to 'day' when nothing is stored or the stored key is no longer a valid
* theme.
*/
export function useInviteThemeKey() {
const [stored, setThemeKey] = useStorage(device, ['inviteFriendsThemeKey'])
const themeKey: InviteThemeKey =
stored && stored in INVITE_THEMES ? stored : 'day'
return [themeKey, setThemeKey] as const
}
+3 -3
View File
@@ -1,20 +1,20 @@
import {
AppBskyFeedDefs,
type AppBskyFeedGetAuthorFeed as GetAuthorFeed,
type BskyAgent,
type AtpAgent,
} from '@atproto/api'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
_params: GetAuthorFeed.QueryParams
constructor({
agent,
feedParams,
}: {
agent: BskyAgent
agent: AtpAgent
feedParams: GetAuthorFeed.QueryParams
}) {
this.agent = agent
+4 -4
View File
@@ -1,7 +1,7 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
AtpAgent,
jsonStringToLex,
} from '@atproto/api'
@@ -13,7 +13,7 @@ import {type FeedAPI, type FeedAPIResponse} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
export class CustomFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
params: GetCustomFeed.QueryParams
userInterests?: string
@@ -22,7 +22,7 @@ export class CustomFeedAPI implements FeedAPI {
feedParams,
userInterests,
}: {
agent: BskyAgent
agent: AtpAgent
feedParams: GetCustomFeed.QueryParams
userInterests?: string
}) {
@@ -113,7 +113,7 @@ async function loggedOutFetch({
* @see https://github.com/bluesky-social/atproto/blob/60df3fc652b00cdff71dd9235d98a7a4bb828f05/packages/api/src/agent.ts#L120
*/
const labelersHeader = {
'atproto-accept-labelers': BskyAgent.appLabelers
'atproto-accept-labelers': AtpAgent.appLabelers
.map(l => `${l};redact`)
.join(', '),
}
+3 -3
View File
@@ -1,12 +1,12 @@
import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {DEMO_FEED} from '#/lib/demo'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class DemoFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
constructor({agent}: {agent: BskyAgent}) {
constructor({agent}: {agent: AtpAgent}) {
this.agent = agent
}
+3 -3
View File
@@ -1,11 +1,11 @@
import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
constructor({agent}: {agent: BskyAgent}) {
constructor({agent}: {agent: AtpAgent}) {
this.agent = agent
}
+3 -3
View File
@@ -1,4 +1,4 @@
import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api'
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {CustomFeedAPI} from './custom'
@@ -27,7 +27,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
}
export class HomeFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
following: FollowingFeedAPI
discover: CustomFeedAPI
usingDiscover = false
@@ -39,7 +39,7 @@ export class HomeFeedAPI implements FeedAPI {
agent,
}: {
userInterests?: string
agent: BskyAgent
agent: AtpAgent
}) {
this.agent = agent
this.following = new FollowingFeedAPI({agent})
+3 -3
View File
@@ -1,20 +1,20 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedGetActorLikes as GetActorLikes,
type BskyAgent,
type AtpAgent,
} from '@atproto/api'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
agent: BskyAgent
agent: AtpAgent
params: GetActorLikes.QueryParams
constructor({
agent,
feedParams,
}: {
agent: BskyAgent
agent: AtpAgent
feedParams: GetActorLikes.QueryParams
}) {
this.agent = agent
+7 -7
View File
@@ -1,7 +1,7 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedGetTimeline,
type BskyAgent,
type AtpAgent,
} from '@atproto/api'
import shuffle from 'lodash.shuffle'
@@ -24,7 +24,7 @@ const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
userInterests?: string
agent: BskyAgent
agent: AtpAgent
params: FeedParams
feedTuners: FeedTunerFn[]
following: MergeFeedSource_Following
@@ -39,7 +39,7 @@ export class MergeFeedAPI implements FeedAPI {
feedTuners,
userInterests,
}: {
agent: BskyAgent
agent: AtpAgent
feedParams: FeedParams
feedTuners: FeedTunerFn[]
userInterests?: string
@@ -175,7 +175,7 @@ export class MergeFeedAPI implements FeedAPI {
}
class MergeFeedSource {
agent: BskyAgent
agent: AtpAgent
feedTuners: FeedTunerFn[]
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
@@ -186,7 +186,7 @@ class MergeFeedSource {
agent,
feedTuners,
}: {
agent: BskyAgent
agent: AtpAgent
feedTuners: FeedTunerFn[]
}) {
this.agent = agent
@@ -253,7 +253,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
}
class MergeFeedSource_Custom extends MergeFeedSource {
agent: BskyAgent
agent: AtpAgent
minDate: Date
feedUri: string
userInterests?: string
@@ -264,7 +264,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
feedTuners,
userInterests,
}: {
agent: BskyAgent
agent: AtpAgent
feedUri: string
feedTuners: FeedTunerFn[]
userInterests?: string

Some files were not shown because too many files have changed in this diff Show More