Compare commits

...

179 Commits

Author SHA1 Message Date
Samuel Newman 14a8adf646 fix chat input on mobile android 2026-06-29 17:14:19 +03:00
Samuel Newman a5ed41fa0f Fix CI - grant workflows permission for internal sync push (#11010) 2026-06-29 16:30:01 +03:00
Samuel Newman 257a11d498 Set apple team id via environment variable (#11008) 2026-06-29 01:59:00 -07:00
RetroSunstar b8608ac03b Fix 149/150 user limit for starter packs (#11001) 2026-06-28 01:09:07 -07:00
pfrazee e0ddd8d509 Nightly source-language update 2026-06-27 03:17:11 +00:00
Eric Bailey 90f3f954ea Fix GrowthBook exposure did mis-attribution across account switches (#11000)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:04:40 -05:00
DS Boyce 248290e0b3 Add advanced search UI (#10992) 2026-06-26 16:16:48 -07:00
Spence Pope c5b3810229 Fail video duration check before compression (#10999) 2026-06-26 17:39:15 -05:00
surfdude29 1756740c6c Add app language: Czech (cs) (#10994) 2026-06-26 16:04:48 -05:00
Samuel Newman 66250e45cc Clean up type/lint errors in modules (#10996) 2026-06-26 07:41:09 -07:00
dependabot[bot] fcbd23e7db Bump actions/download-artifact from 7.0.0 to 8.0.1 (#10971)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 07:40:12 -07:00
pfrazee 717406f16c Nightly source-language update 2026-06-26 03:18:48 +00:00
Gabe Sullivan ea35d930e3 Change dialog label and header from 'Invite Friends' to 'Share Profile' (#10987) 2026-06-25 18:23:06 -05:00
Eric Bailey 308693762c Add placeholder, empty-value, and onConfirm support to DateField (#10993)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:04:54 -05:00
Spence Pope fcf17a482f Add video upload telemetry events (#10991) 2026-06-25 18:14:05 -04:00
DS Boyce 6b342f61b4 Hide autocomplete suggestions when input loses focus (#10989) 2026-06-25 16:59:50 -05:00
DS Boyce eae15aca3e Increase unread cap to 99+ (#10985) 2026-06-25 16:57:03 -05:00
Lynx 29a0d16b69 Add icon to AppLanguageDropdown (#10977) 2026-06-25 16:54:31 -05:00
Alex Benzer c33a35851e Show shorter text for account label badge (#10983) 2026-06-25 16:53:49 -05:00
Eric Bailey b1b0ad09cc Video alt text (#10990)
Co-authored-by: Zaven477 <77801431+Zaven477@users.noreply.github.com>
2026-06-25 16:52:09 -05:00
DS Boyce 46f5437faa Display more information in chat replies (#10950) 2026-06-25 15:03:42 -05:00
DS Boyce 3b2e609761 Add new chat notification preferences (#10972) 2026-06-25 12:01:33 -07:00
DS Boyce 1ff27bfd3b Upgrade pnpm to 11.9.0 (#10952) 2026-06-25 13:43:33 -05:00
pfrazee f72aae3bac Nightly source-language update 2026-06-25 03:18:16 +00:00
DS Boyce 51e25435a2 Focus composer input when editable (#10982) 2026-06-24 18:06:02 -05:00
DS Boyce da1f637160 Fix issue with stale unread message counts (#10953) 2026-06-24 13:51:06 -07:00
Spence Pope f64b1258b6 [APP-2429] Add Sunlight and Twilight color options to the Invite Friends QR code (#10921)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-24 12:27:12 -05:00
Lynx c909fef3f1 signup: align "Contact support" to the right on larger displays (#10978) 2026-06-24 12:26:25 -05:00
DS Boyce 5b66015631 Set a min height on native Menus (#10956) 2026-06-24 11:51:16 -05:00
surfdude29 90c010e98a Tweak strings and context (#10928) 2026-06-24 11:39:12 -05:00
Samuel Newman f3978d1a1f Fix internal repo sync broken by actions/checkout v6 bump (#10933)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:38:44 -05:00
Samuel Newman 2843374ef8 Fix setState-in-render warning from convo cache subscription (#10934)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:37:57 -05:00
RetroSunstar 0674626f3c Add light haptics to Edit Profile Button for labellers (#10948) 2026-06-24 11:33:49 -05:00
DS Boyce cd4d62c75a Remove old message composer (#10951) 2026-06-24 11:27:10 -05:00
DS Boyce c3626c80d1 Fix embeds overlapping each other in chat (#10949) 2026-06-24 11:14:23 -05:00
DS Boyce e371d1db44 Add mark all as read to chat settings (#10973) 2026-06-24 10:23:57 -05:00
Spence Pope 4308d75e49 Add TestFlight group selection to iOS build workflow (#10979) 2026-06-24 10:52:58 -04:00
pfrazee ea0ef23340 Nightly source-language update 2026-06-24 03:18:32 +00:00
Michael Black 692d8e5d10 bskyweb: add isPartOf jsonld post attr (#10945) 2026-06-23 12:07:00 -05:00
Spence Pope c753c94969 Process videos on web with WebCodecs (#10955)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-23 11:33:23 -04:00
pfrazee cc0e1f88ea Nightly source-language update 2026-06-23 03:17:56 +00:00
Jerry Chen 495d1fe9a7 Restore ALT badges in quoted posts (#10964)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-22 14:05:50 -05: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
Jim Calabro 5c04a41a11 Add a Metrics Server to bskyweb (#10863) 2026-06-11 08:52:12 -04:00
Eric Bailey 0187a49534 Release Prep 1.124.0 (#10859) 2026-06-10 17:00:41 -05:00
DS Boyce d3f8e740de Log when a chat invite is posted or messaged (#10858) 2026-06-10 14:44:36 -07:00
Eric Bailey b4dff0f4b7 Let text wrap in invite dialog (#10855) 2026-06-10 16:06:09 -05:00
DS Boyce 871e9506df Add new analytics events for group chats (#10830) 2026-06-10 13:56:39 -07:00
Spence Pope 974d2b15d5 Fix stale push notification replay after account switch (#10792) 2026-06-10 15:46:35 -05:00
Spence Pope b667cafca1 Use add-only photo permission when saving QR codes (#10854) 2026-06-10 16:39:40 -04:00
Samuel Newman 156903070b [Chat] Power conversation settings screen via useConvoQuery (#10853)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:23:47 -07:00
estrattonbailey 3feea02548 Nightly source-language update 2026-06-10 19:37:34 +00:00
Samuel Newman 2efdc3fe35 [Chat] NUX (#10848)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-10 14:35:35 -05:00
Samuel Newman 2d896983ab Peek preview for external link embeds (#10704)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:35:16 -05:00
DS Boyce b47948dca7 Add image to invite link NUX (#10852) 2026-06-10 11:57:24 -07:00
Samuel Newman 8db30c7d31 [Chat] Handle moderation lock override in group chats (#10835)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:57:12 -07:00
Samuel Newman 3b5ede37f7 [Chat] Reusable loading/unavailable states for chat invites (#10819)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:54:37 -07:00
Michael Black 3095bbfbec bskyweb: add video objects to jsonld SEO (#10814) 2026-06-10 13:54:24 -05:00
Samuel Newman b3985bfad5 Add indication of total number of images in ComposerReplyTo (#10836)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-10 11:49:52 -07:00
Spence Pope c6f9803676 Add profile QR code for sharing and inviting friends (#10659) 2026-06-10 14:14:45 -04:00
DS Boyce adb39b4442 Invert group chat feature gate (#10851) 2026-06-10 10:41:34 -07:00
DS Boyce 01054624ef Restore on-device translation from overflow menu (#10850) 2026-06-10 12:31:48 -05:00
Alex Benzer 8c8617d5f2 New layout for expanded notification bundles (#10664)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-10 12:23:07 -05:00
DS Boyce 50dabf3f65 Enable Google Translate in post overflow menu (#10849) 2026-06-10 10:14:48 -07:00
Samuel Newman e5335fa52b [Chat] Fix duplicate messages when history fetch races firehose (#10839)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:44:44 -07:00
Samuel Newman 9e9a4024c3 [Chat] Delete GroupChatGate (#10846) 2026-06-10 09:44:28 -07:00
Eric Bailey 209ecafdc1 Fix video preview collapse (#10847) 2026-06-10 11:30:48 -05:00
DS Boyce 90f8fe59ba Put display name and handle on one line in invite dialog (#10824) 2026-06-10 09:17:09 -07:00
DS Boyce a69836216c Tweak spacing in invite link dialog (#10829) 2026-06-10 09:09:19 -07:00
Samuel Newman 8fcf25b290 [Chat] Fix source of truth for group size limit (#10845) 2026-06-10 08:17:18 -07:00
Eric Bailey 8b4a5238be [Chat] Sync convo agent with shadow cache (#10827)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-10 08:16:58 -07:00
Samuel Newman 17ff9af200 [Chat] Some tweaks (#10840) 2026-06-10 06:38:31 -07:00
Samuel Newman 7379ad3c3b [Chat] Fix optimistic updates by using partial keys (#10838)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:56:54 -07:00
Samuel Newman 9a2e636c17 Remove leave-convo suppression, use real optimistic update (#10834)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:58:16 -07:00
Samuel Newman f321122656 use opus 4.8 in github claude (#10837) 2026-06-10 01:55:03 -07:00
Samuel Newman d6a14ade54 [Chat] revalidate join links on leave (#10833) 2026-06-10 01:33:21 -07:00
DS Boyce 8720d05242 Clear search term after adding chat member (#10831) 2026-06-09 23:47:13 -07:00
DS Boyce fc14427134 Support dim theme on join request landing page (#10832) 2026-06-09 23:31:45 -07:00
pfrazee 76694a2ba8 Nightly source-language update 2026-06-10 03:18:50 +00:00
Michael Black 38fb9c2165 bskyweb: meta noindex tag for non-indexable pages (#10803) 2026-06-09 15:54:54 -05:00
Eric Bailey db2e190f4c Hide domain if matches handle, new Standard Site icon treatment (#10767) 2026-06-09 15:54:16 -05:00
Eric Bailey 7f6cfaee71 Fix image collapse in FF (#10826) 2026-06-09 15:15:37 -05:00
DS Boyce 0558e8b79a Change Ignore to Reject for chat invites (#10825) 2026-06-09 12:40:35 -07:00
Samuel Newman 852cb53cab [Chat] add lil gap between chats on web (#10821) 2026-06-09 22:24:28 +03:00
DS Boyce c27f50e038 Add list of mutual chats to block dialog (#10727)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:18:58 -05:00
DS Boyce 189dad8cb7 Address chat design nits (#10823) 2026-06-09 11:36:08 -07:00
DS Boyce de5ae04809 Fix link to profile from chat invite embed (#10822) 2026-06-09 11:35:45 -07:00
Samuel Newman 6ba5154bcc [Chat] Show invite link label in chat list preview (#10811)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:07:45 -07:00
DS Boyce 994e27ded1 Don't suggest users who can't be messaged/added (#10820) 2026-06-09 10:45:50 -07:00
DS Boyce df8c731f1d Fix back button behavior on conversation settings screen (#10801)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-09 10:16:00 -07:00
Samuel Newman 3fdfebfe29 [Chat] Surface blocked members to top of group member list for owner (#10726)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:15:03 -07:00
DS Boyce 750c55eff8 Display toast after reacting emoji reaction limit (#10799) 2026-06-09 09:36:35 -07:00
DS Boyce 986cf62da1 Fix text truncating in image menu on iOS (#10802) 2026-06-09 09:35:49 -07:00
Michael Black be30947089 bskyweb: extract gallery media for SEO (#10813) 2026-06-09 11:10:33 -05:00
Samuel Newman fb4133cdc7 [Chat] Filter out groups from badge counts when AA'd (#10816) 2026-06-09 18:33:04 +03:00
Spence Pope e339a2a18c version number (#10815) 2026-06-09 08:24:34 -07:00
DS Boyce c52231a449 Update handling of join link previews (#10798)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-09 07:23:33 -07:00
Samuel Newman 4c4ebd0510 Catch NotAllowedError in web GIF player + lint modules/ (#10806) 2026-06-09 07:20:34 -07:00
Spence Pope bc94c8c07b Fix composer horizontal swipe triggering browser back/forward (#10812) 2026-06-09 10:11:21 -04:00
Samuel Newman 22643c76d0 Fix fullscreen infinite render loop in video embeds (#10807) 2026-06-09 06:51:36 -07:00
Samuel Newman 84c4223ff9 [Chat] Gate group chats for under-18 users (#10548)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-09 06:31:22 -07:00
surfdude29 2cb225dddd Mark string for translation and add context (#10805) 2026-06-09 06:03:52 -07:00
DS Boyce 8015c26955 Hide last message in chat list if it's blocked (#10800) 2026-06-09 08:13:15 +03:00
pfrazee c6e6b83d50 Nightly source-language update 2026-06-09 03:18:45 +00:00
DS Boyce bfbd1cef32 Upgrade to pnpm 11.5.2 (#10793) 2026-06-08 13:53:09 -07:00
Eric Bailey 690b8184a1 Refactor age assurance flags and contexts (#10794) 2026-06-08 15:34:51 -05:00
Samuel Newman 46e54d2375 [Chat] Try and resolve loading bug once and for all (#10791) 2026-06-08 23:14:43 +03:00
Samuel Newman 38ee14d517 Remove social proof from group invites (#10787) 2026-06-08 23:14:29 +03:00
DS Boyce 1c5b3dd3a1 Only show Message option if chat member can be messaged (#10788) 2026-06-08 12:15:13 -07:00
DS Boyce 0c89198ded Remove TODO for uninviting group chat members (#10789) 2026-06-08 12:14:51 -07:00
DS Boyce fe9dabbc4a Open edit chat name dialog via tap (#10790) 2026-06-08 12:14:24 -07:00
DS Boyce 4142eb4834 Add fade-in animation to chat footer/composer (#10786) 2026-06-08 12:13:43 -07:00
Samuel Newman 9b5fc19613 [Chat] Centralize conversation report-subject resolution (#10754)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:07:35 -07:00
Spence Pope 0090285fc0 Refactor photo embed analytics to post:photoEmbed:* namespace (#10784)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:44:59 -04:00
Samuel Newman c4f3a2cb89 [Chat] Rename invite route to /chat/ (#10782) 2026-06-08 09:26:23 -07:00
Samuel Newman d9345cb1cf [Chat] Fix footer logic (#10785) 2026-06-08 09:20:37 -07:00
434 changed files with 112505 additions and 66192 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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 }}```"}
+111 -15
View File
@@ -10,6 +10,35 @@ on:
options:
- testflight
- production
testFlightGroup:
type: choice
description: TestFlight group to assign the build to after submitting
options:
- none
- QA Team
- Software Mansion
default: none
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
testFlightGroup:
type: string
description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none
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 +48,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 +60,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 +113,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 +192,76 @@ 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.testFlightGroup != 'none' }}
env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
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:"$TESTFLIGHT_GROUP" \
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-7
+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
testFlightGroup: "QA Team"
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
+15 -3
View File
@@ -4,28 +4,40 @@ 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
# Don't persist the checkout auth header; the push below authenticates
# with the app token embedded in the remote URL instead
persist-credentials: false
- 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. The workflows
# permission is required because the sync includes files under
# .github/workflows/, which GitHub refuses to push without it.
permission-contents: write
permission-workflows: write
- name: Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
+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()
}
+34 -1
View File
@@ -164,12 +164,45 @@ related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Documentation and Tests Within Features
### Comments
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
Always use docblock (`/** */`) syntax for comments that document a type, type
member, method, function, or variable. These are the comments a reader expects
to find attached to a named declaration, and the docblock form makes that intent
clear and surfaces nicely in editor tooltips.
```tsx
type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
}
/**
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
*/
export function DateField() {}
```
More generally, any multiline comment should use the `/* */` block syntax rather
than stacked `//` lines. Reserve `//` for short, single-line comments.
```tsx
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today.
*/
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
```
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
+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.0 && \
npm install --global pnpm@11.9.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"
+20 -20
View File
@@ -184,39 +184,39 @@ describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/c/abcdefg', 'abcdefg'],
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
['https://bsky.app/chat/abcdefg', 'abcdefg'],
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
['http://bsky.app/c/abcdefg', undefined],
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
['/c/abcdefg', 'abcdefg'],
['/c/abcdefg?utm=foo', 'abcdefg'],
['/c/abcdefg#section', 'abcdefg'],
['http://bsky.app/chat/abcdefg', undefined],
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
['/chat/abcdefg', 'abcdefg'],
['/chat/abcdefg?utm=foo', 'abcdefg'],
['/chat/abcdefg#section', 'abcdefg'],
// too short
['https://bsky.app/c/abcdef', undefined],
['/c/abcdef', undefined],
['https://bsky.app/chat/abcdef', undefined],
['/chat/abcdef', undefined],
// too long
['https://bsky.app/c/abcdefghijk', undefined],
['/c/abcdefghijk', undefined],
['https://bsky.app/chat/abcdefghijk', undefined],
['/chat/abcdefghijk', undefined],
// invalid characters
['https://bsky.app/c/abc-def', undefined],
['/c/abc def', undefined],
['https://bsky.app/chat/abc-def', undefined],
['/chat/abc def', undefined],
// trailing path
['https://bsky.app/c/abcdefg/extra', undefined],
['/c/abcdefg/extra', undefined],
['https://bsky.app/chat/abcdefg/extra', undefined],
['/chat/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
['https://bsky.app/c', undefined],
['https://bsky.app/chat', undefined],
// wrong host
['https://example.com/c/abcdefg', undefined],
['https://example.com/chat/abcdefg', undefined],
// not a url, not a path
['c/abcdefg', undefined],
['chat/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
['https://[invalid/c/abcdefg', undefined],
['https://[invalid/chat/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
+2
View File
@@ -59,6 +59,7 @@ module.exports = function (_config) {
ios: {
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
appleTeamId: process.env.EXPO_APPLE_TEAM_ID,
config: {
usesNonExemptEncryption: false,
},
@@ -81,6 +82,7 @@ module.exports = function (_config) {
'an',
'ast',
'ca',
'cs',
'cy',
'da',
'de',

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+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.6",
"@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.6
version: 0.20.6
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.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
'@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.6':
'@atproto/api@0.20.13':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!found) {
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
return res.status(404).end('not found')
}
preview = found
+4 -15
View File
@@ -4,10 +4,9 @@ import (
appbsky "github.com/bluesky-social/indigo/api/bsky"
)
// Helpers for extracting Open Graph metadata from post embeds. These feed
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
// VideoObject (deferred — needs `duration` from the appview).
// Helpers for extracting Open Graph metadata from post embeds. og:video
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
// the two outputs cannot drift.
// videoMeta holds og:video meta tag data.
type videoMeta struct {
@@ -21,17 +20,7 @@ type videoMeta struct {
// extractVideoMeta returns og:video metadata, or the zero value if there's
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
if pv == nil || pv.Embed == nil || embedHidden {
return videoMeta{}
}
var v *appbsky.EmbedVideo_View
if pv.Embed.EmbedVideo_View != nil {
v = pv.Embed.EmbedVideo_View
} else if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
v := findVideoEmbed(pv, embedHidden)
if v == nil || v.Playlist == "" {
return videoMeta{}
}
+192 -9
View File
@@ -59,14 +59,30 @@ type discussionForumPosting struct {
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
Video *videoObject `json:"video,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
// videoObject is the schema.org VideoObject shape for video embeds.
// duration is omitted because the appview does not expose it.
type videoObject struct {
Type string `json:"@type"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
UploadDate string `json:"uploadDate,omitempty"`
ContentURL string `json:"contentUrl,omitempty"`
EmbedURL string `json:"embedUrl,omitempty"`
Width int64 `json:"width,omitempty"`
Height int64 `json:"height,omitempty"`
}
// comment is the schema.org Comment shape used in
// DiscussionForumPosting.comment[]. The comment property does not accept
// DiscussionForumPosting, so replies map to Comment.
@@ -78,6 +94,7 @@ type comment struct {
Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
Video *videoObject `json:"video,omitempty"`
DatePublished string `json:"datePublished,omitempty"`
}
@@ -127,6 +144,31 @@ func bskyPostURLFromATURI(handle, atURI string) string {
return bskyPostURL(handle, parsed.RecordKey().String())
}
// bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL
// when the handle is usable, otherwise falls back to the DID-form URL
// derived from the AT-URI's authority. Returns "" only when the AT-URI is
// unparseable or has no record key. Used for nested fields (e.g.
// VideoObject.embedUrl) where omitting on handle.invalid would weaken the
// emitted structured data.
func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string {
parsed, err := syntax.ParseATURI(atURI)
if err != nil {
return ""
}
rkey := parsed.RecordKey().String()
if rkey == "" {
return ""
}
if url := bskyPostURL(handle, rkey); url != "" {
return url
}
did := parsed.Authority().String()
if did == "" {
return ""
}
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey)
}
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
// the handle is unusable.
func bskyProfileURL(handle string) string {
@@ -136,9 +178,9 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image or video
// embed, byte-identical to what we put in og:image. Callers derive
// thumbnailUrl from urls[0].
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
// or video embed, byte-identical to what we put in og:image. Callers
// derive thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
@@ -147,6 +189,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
if pv.Embed.EmbedGallery_View != nil {
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
@@ -155,6 +200,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
if media.EmbedGallery_View != nil {
return galleryThumbs(media.EmbedGallery_View.Items)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
@@ -174,6 +222,89 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls
}
// galleryThumbs returns the thumbnail URLs of image items in a gallery
// embed, or nil if empty. Items_Elem is a union; non-image variants and
// nil entries are skipped so future gallery item types don't break SEO
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
// <meta property="og:image" content=""> if the appview ever returns one.
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
if len(items) == 0 {
return nil
}
urls := make([]string, 0, len(items))
for _, item := range items {
if item == nil || item.EmbedGallery_ViewImage == nil {
continue
}
if item.EmbedGallery_ViewImage.Thumbnail == "" {
continue
}
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
}
if len(urls) == 0 {
return nil
}
return urls
}
// findVideoEmbed returns the post's video embed view, or nil if there is
// none or embeds are hidden. Shared with extractVideoMeta so og:video and
// JSON-LD VideoObject stay in sync.
func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
}
if pv.Embed.EmbedVideo_View != nil {
return pv.Embed.EmbedVideo_View
}
if pv.Embed.EmbedRecordWithMedia_View != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
}
return nil
}
// buildVideoObject returns a VideoObject for the post's video embed, or
// nil if there's no usable video. Falls back to "Video by @<handle>" when
// alt text is empty so name is always populated (Google requires it).
// description falls back to name for the same reason.
func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject {
v := findVideoEmbed(pv, embedHidden)
if v == nil || v.Playlist == "" {
return nil
}
vo := &videoObject{
Type: "VideoObject",
ContentURL: v.Playlist,
EmbedURL: embedURL,
// uploadDate uses IndexedAt (not record CreatedAt) for consistency
// with DiscussionForumPosting.datePublished on the parent post.
UploadDate: pv.IndexedAt,
}
if v.Thumbnail != nil {
vo.ThumbnailURL = *v.Thumbnail
}
switch {
case v.Alt != nil && *v.Alt != "":
vo.Name = *v.Alt
case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid":
vo.Name = "Video by @" + pv.Author.Handle
default:
vo.Name = "Video on Bluesky"
}
if postText != "" {
vo.Description = postText
} else {
vo.Description = vo.Name
}
if v.AspectRatio != nil {
vo.Width = v.AspectRatio.Width
vo.Height = v.AspectRatio.Height
}
return vo
}
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
@@ -214,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return ""
}
// threadRootURI returns the AT-URI of the root post of the thread a reply
// belongs to, or "" if the post is not a reply or the record is malformed.
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
return ""
}
return rec.Reply.Root.Uri
}
// findRootPostInParents walks tv's parent chain upward and returns the
// PostView whose URI matches rootURI, or nil if the root is not present.
// The root is absent when the chain is truncated by parentHeight (reply
// deeper than the fetched height) or broken by a blocked/not-found parent.
// Avoids a separate FeedGetPosts call when the thread response already
// contains the root.
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
if rootURI == "" {
return nil
}
for node := tv; node != nil; {
if node.Post != nil && node.Post.Uri == rootURI {
return node.Post
}
if node.Parent == nil {
return nil
}
// Only threadViewPost parents continue the chain; a blocked or
// not-found parent breaks it before reaching the root.
node = node.Parent.FeedDefs_ThreadViewPost
}
return nil
}
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -363,14 +531,18 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
thumb = images[0]
}
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
postText := postRecordText(pv)
node := discussionForumPosting{
Type: "DiscussionForumPosting",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
URL: postURL,
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Text: postText,
Image: images,
ThumbnailURL: thumb,
Video: buildVideoObject(pv, postURL, postText, embedHidden),
DatePublished: pv.IndexedAt,
InteractionStat: buildPostStats(pv),
}
@@ -433,27 +605,38 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
if len(images) > 0 {
thumb = images[0]
}
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
postText := postRecordText(pv)
return comment{
Type: "Comment",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
URL: postURL,
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
Text: postText,
Image: images,
ThumbnailURL: thumb,
Video: buildVideoObject(pv, postURL, postText, embedHidden),
DatePublished: pv.IndexedAt,
}
}
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
// non-empty, is the handle-form canonical URL of the thread root the handler
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
// not be resolved). We never emit a DID-form isPartOf because it would not
// match the root page's handle-form canonical.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
+631 -21
View File
@@ -72,6 +72,60 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
}
}
// withGallery adds an app.bsky.embed.gallery view with image items.
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
}
}
}
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
// is an app.bsky.embed.gallery view.
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: qDid,
Handle: qHandle,
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
},
},
}
}
}
// withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -129,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
}
}
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
// strong-ref to the given thread-root post.
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
rec.Reply = &appbsky.FeedPost_ReplyRef{
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
}
}
}
// withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -209,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -279,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -299,10 +366,92 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
}
}
func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok {
t.Fatalf("image should be array, got %T", main["image"])
}
if len(imgs) != 3 {
t.Errorf("expected 3 gallery images, got %d", len(imgs))
}
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
t.Errorf("gallery image[] order wrong: %v", imgs)
}
if main["thumbnailUrl"] != thumb1 {
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
}
}
// Gallery in the media slot of a record-with-media embed should still
// produce og:image / JSON-LD image[]. Quote-post URL still emits
// alongside via isBasedOn.
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok || len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
}
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
}
}
// Forward-compat: nil items, unknown-variant union elements, and empty
// Thumbnail strings must be skipped, not panic or leak as <meta
// property="og:image" content="">. Unknown variants are dropped silently
// so older deploys keep working when new gallery item types ship.
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{
nil,
{}, // empty union, no variant set
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
},
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0] != thumb {
t.Errorf("expected single thumb, got %v", got)
}
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
},
}
if got := extractPostMedia(pv, false); got != nil {
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
}
}
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -315,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -324,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn")
}
}
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
// it is emitted on the main post; when empty, no isPartOf is present.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isPartOf"] != isPartOf {
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
}
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isPartOf"]; present {
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
}
}
func TestThreadRootURI(t *testing.T) {
// A reply returns its root AT-URI; a non-reply returns "".
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
withReplyRoot("did:plc:root", "rootrkey"))
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
if got := threadRootURI(reply); got != want {
t.Errorf("threadRootURI = %q, want %q", got, want)
}
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
if got := threadRootURI(post); got != "" {
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
}
}
func TestFindRootPostInParents(t *testing.T) {
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
rootURI := rootPost.Uri
// tvp wraps a PostView, optionally chaining to a parent thread node.
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
if parent != nil {
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
}
return node
}
t.Run("direct reply, parent is root", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post, got %v", got)
}
})
t.Run("multi-level chain", func(t *testing.T) {
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post in chain, got %v", got)
}
})
t.Run("root absent, chain truncated", func(t *testing.T) {
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when root absent, got %v", got)
}
})
t.Run("chain broken by blocked parent", func(t *testing.T) {
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
// breaking the walk before the root.
leaf := &appbsky.FeedDefs_ThreadViewPost{
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
},
}
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
}
})
t.Run("empty rootURI", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, ""); got != nil {
t.Errorf("expected nil for empty rootURI, got %v", got)
}
})
}
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
// Replies surfaced under the main post as comment[] are Comment nodes and
// never carry isPartOf, even when the main post has one.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
if _, present := c["isPartOf"]; present {
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
}
}
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any)
if !ok {
@@ -351,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image")
@@ -361,11 +616,30 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
}
}
// Symmetric guard for the gallery extraction path. Functionally redundant
// with the early-return at the top of extractPostMedia, but exists so the
// hide-embed contract is asserted directly against the gallery branch -
// catches anyone who later moves the embedHidden check inside an
// embed-shape branch.
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
}
}
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -401,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
})
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -443,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope
@@ -492,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical {
@@ -509,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil")
}
}
@@ -527,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
}
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -548,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
}
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any)
@@ -755,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -768,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -784,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -796,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -807,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any)
@@ -948,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -988,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
@@ -1049,3 +1323,339 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
t.Errorf("verifier identifier wrong: %v", rb[0])
}
}
// videoEmbedOpts configures withVideoFull. Zero values mean "not set".
type videoEmbedOpts struct {
thumbnail string
playlist string
alt string
width int64
height int64
hasAspect bool
recordMedia bool // nest under EmbedRecordWithMedia_View.Media
}
// withVideoFull installs a fully-specified video embed for VideoObject tests.
func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
v := &appbsky.EmbedVideo_View{Playlist: o.playlist}
if o.thumbnail != "" {
v.Thumbnail = strPtr(o.thumbnail)
}
if o.alt != "" {
v.Alt = strPtr(o.alt)
}
if o.hasAspect {
v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height}
}
if o.recordMedia {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://did:plc:quoted/app.bsky.feed.post/q",
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:quoted",
Handle: "quoted.bsky.social",
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedVideo_View: v,
},
},
}
} else {
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: v,
}
}
}
}
func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "A trip to the park",
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing from mainEntity")
}
if video["@type"] != "VideoObject" {
t.Errorf("@type = %v, want VideoObject", video["@type"])
}
if video["name"] != "A trip to the park" {
t.Errorf("name = %v, want alt text", video["name"])
}
if video["description"] != "watch this" {
t.Errorf("description = %v, want post text", video["description"])
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
if video["uploadDate"] != pv.IndexedAt {
t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if w, _ := video["width"].(float64); int64(w) != 16 {
t.Errorf("width = %v, want 16", video["width"])
}
if h, _ := video["height"].(float64); int64(h) != 9 {
t.Errorf("height = %v, want 9", video["height"])
}
// post thumbnailUrl should equal the video thumb (byte-equal og:image).
if main["thumbnailUrl"] != thumb {
t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb)
}
}
func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
// No alt text -> "Video by @<handle>".
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing")
}
if video["name"] != "Video by @alice.bsky.social" {
t.Errorf("name fallback = %v", video["name"])
}
}
func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
// handle.invalid + no alt -> generic fallback.
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
t.Errorf("name fallback = %v, want generic", video["name"])
}
}
func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
// Empty post text -> description falls back to name so Google's video
// rich-result requirement (description present) is satisfied.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
t.Errorf("description fallback = %v, want name", video["description"])
}
}
func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
t.Errorf("width should be omitted when AspectRatio missing")
}
if _, present := video["height"]; present {
t.Errorf("height should be omitted when AspectRatio missing")
}
}
func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
// No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set
// from the video thumb (existing behavior).
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"])
}
}
func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
// Self-labeled hide drops the video, parallel to the image-hide test.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withVideoFull(videoEmbedOpts{
thumbnail: "https://cdn.bsky.app/img/x@jpeg",
playlist: "https://video.bsky.app/p.m3u8",
alt: "should be dropped",
}),
withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
}
}
func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on record-with-media post")
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
if video["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb)
}
// quote-post still surfaces via isBasedOn alongside the video.
if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" {
t.Errorf("isBasedOn = %v", main["isBasedOn"])
}
}
func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg"
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
if video["@type"] != "VideoObject" {
t.Errorf("reply video @type = %v", video["@type"])
}
if video["name"] != "bob's clip" {
t.Errorf("reply video name = %v", video["name"])
}
if video["contentUrl"] != playlist {
t.Errorf("reply video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" {
t.Errorf("reply video embedUrl = %v", video["embedUrl"])
}
}
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
}
}
func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) {
pv := newProfileViewDetailed()
playlist := "https://video.bsky.app/p.m3u8"
post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "alt",
}))
out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
page := unmarshalLD(t, out)
hp := page["hasPart"].([]any)
if len(hp) != 1 {
t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
}
video, ok := hp[0].(map[string]any)["video"].(map[string]any)
if !ok {
t.Fatalf("hasPart entry should carry video, got %v", hp[0])
}
if video["contentUrl"] != playlist {
t.Errorf("hasPart video contentUrl = %v", video["contentUrl"])
}
if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" {
t.Errorf("hasPart video embedUrl = %v", video["embedUrl"])
}
}
// handle.invalid authors must still produce a non-empty embedUrl on the
// VideoObject. The handle-form URL is unusable, so we fall back to the
// DID-form URL derived from the AT-URI authority. Without this fallback,
// omitempty drops embedUrl and Google's video indexer loses the canonical
// page reference.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist := "https://video.bsky.app/p.m3u8"
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
t.Fatalf("video missing on handle.invalid post")
}
if video["embedUrl"] != canonical {
t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical)
}
if video["contentUrl"] != playlist {
t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist)
}
}
// Same fallback applies to videos on replies whose author is handle.invalid.
func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 1
playlist := "https://video.bsky.app/bob.m3u8"
reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch",
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
if !ok {
t.Fatalf("reply video missing")
}
want := "https://bsky.app/profile/did:plc:bob/post/rep1"
if video["embedUrl"] != want {
t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want)
}
}
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
}
return false
}
// postAuthorRequiresAuth reports whether the post author self-applied the
// !no-unauthenticated label, read from the author view embedded in a
// getPostThread response. The appview surfaces the account's profile-record
// self-labels on the post author (src == author DID), so this mirrors
// profileRequiresAuth without a separate ActorGetProfile call.
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
if pv == nil || pv.Author == nil {
return false
}
for _, label := range pv.Author.Labels {
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
+73
View File
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
})
}
}
func TestPostAuthorRequiresAuth(t *testing.T) {
negTrue := true
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
return &appbsky.FeedDefs_PostView{
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:alice",
Handle: "alice.bsky.social",
Labels: labels,
},
}
}
tests := []struct {
name string
pv *appbsky.FeedDefs_PostView
want bool
}{
{
name: "nil post view",
pv: nil,
want: false,
},
{
name: "nil author",
pv: &appbsky.FeedDefs_PostView{},
want: false,
},
{
name: "no labels",
pv: authorPV(nil),
want: false,
},
{
name: "self-applied !no-unauthenticated",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
}),
want: true,
},
{
name: "label from a different src does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
}),
want: false,
},
{
name: "different label value does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "spam"},
}),
want: false,
},
{
// Negation isn't honored - matches profileRequiresAuth behavior.
name: "negated label still triggers (matches profile behavior)",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
}),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
+7
View File
@@ -59,6 +59,13 @@ func run(args []string) {
Value: ":8100",
EnvVars: []string{"HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "metrics-address",
Usage: "Specify the local IP/port to bind the metrics server to",
Required: false,
Value: ":9090",
EnvVars: []string{"METRICS_HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "link-host",
Usage: "scheme, hostname, and port of link service",
+93 -5
View File
@@ -43,9 +43,16 @@ func extractJSONLD(t *testing.T, html string) string {
return strings.TrimSpace(m[1])
}
func TestRenderBase_NoindexMeta(t *testing.T) {
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
}
}
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -74,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -98,10 +105,44 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
}
}
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
// contract that legacy images posts do. Regression guard for the
// app.bsky.embed.gallery extraction path.
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": thumbs,
})
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
}
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
imgs, main["thumbnailUrl"])
}
}
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -167,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -192,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -209,3 +250,50 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
// Auth-required posts must emit noindex,nofollow so the stub page (no body
// text, no comments) is not indexed.
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
html := renderTemplate(t, "post.html", pongo2.Context{
"requiresAuth": true,
"profileHandle": "alice.bsky.social",
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
}
}
// Auth-required profiles must emit noindex,nofollow.
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
pv := newProfileViewDetailed()
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
}
}
// Public posts must NOT emit a robots meta tag. Guards against an accidental
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
if strings.Contains(html, `<meta name="robots"`) {
t.Errorf("public post should not emit robots meta; got:\n%s", html)
}
}
+193 -84
View File
@@ -12,6 +12,7 @@ import (
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"net/url"
@@ -35,15 +36,17 @@ import (
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/urfave/cli/v2"
)
type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
echo *echo.Echo
httpd *http.Server
metricsHttpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
ipccClient http.Client
@@ -67,6 +70,7 @@ type Config struct {
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
metricsAddress := cctx.String("metrics-address")
appviewHost := cctx.String("appview-host")
chatHost := cctx.String("chat-host")
ogcardHost := cctx.String("ogcard-host")
@@ -88,7 +92,7 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
// optional client for the chat appview, used by /c/<code> for OG previews.
// optional client for the chat appview, used by /chat/<code> for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
@@ -296,50 +300,50 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/topic/:topic", server.WebGeneric)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/moderation/verification-settings", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/notifications", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/search", server.WebGenericNoindex)
e.GET("/feeds", server.WebGenericNoindex)
e.GET("/notifications", server.WebGenericNoindex)
e.GET("/notifications/settings", server.WebGenericNoindex)
e.GET("/notifications/activity", server.WebGenericNoindex)
e.GET("/lists", server.WebGenericNoindex)
e.GET("/moderation", server.WebGenericNoindex)
e.GET("/moderation/modlists", server.WebGenericNoindex)
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
e.GET("/settings", server.WebGenericNoindex)
e.GET("/settings/language", server.WebGenericNoindex)
e.GET("/settings/app-passwords", server.WebGenericNoindex)
e.GET("/settings/following-feed", server.WebGenericNoindex)
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
e.GET("/settings/threads", server.WebGenericNoindex)
e.GET("/settings/external-embeds", server.WebGenericNoindex)
e.GET("/settings/accessibility", server.WebGenericNoindex)
e.GET("/settings/appearance", server.WebGenericNoindex)
e.GET("/settings/account", server.WebGenericNoindex)
e.GET("/settings/automation-label", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
e.GET("/settings/content-and-media", server.WebGenericNoindex)
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/inbox", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
e.GET("/messages/:conversation/settings", server.WebGeneric)
e.GET("/messages/:conversation/requests", server.WebGeneric)
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
e.GET("/messages", server.WebGenericNoindex)
e.GET("/messages/inbox", server.WebGenericNoindex)
e.GET("/messages/:conversation", server.WebGenericNoindex)
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
@@ -363,14 +367,14 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
e.GET("/c/:code", server.WebChatInvite)
e.GET("/chat/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGeneric)
e.GET("/saved", server.WebGenericNoindex)
// ipcc
e.GET("/ipcc", server.WebIpCC)
@@ -387,6 +391,19 @@ func serve(cctx *cli.Context) error {
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
}
metricsHttpd, metricsListener, err := newMetricsHTTPServer(metricsAddress)
if err != nil {
return err
}
server.metricsHttpd = metricsHttpd
log.Infof("starting metrics server address=%s", metricsAddress)
go func() {
if err := metricsHttpd.Serve(metricsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("metrics HTTP server shutting down unexpectedly: %s", err)
}
}()
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
@@ -419,6 +436,24 @@ func serve(cctx *cli.Context) error {
return nil
}
func newMetricsHTTPServer(address string) (*http.Server, net.Listener, error) {
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", promhttp.Handler())
metricsHttpd := &http.Server{
Addr: address,
Handler: metricsMux,
ReadHeaderTimeout: 5 * time.Second,
}
metricsListener, err := net.Listen("tcp", address)
if err != nil {
return nil, nil, fmt.Errorf("listen metrics address %s: %w", address, err)
}
return metricsHttpd, metricsListener, nil
}
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
srv.echo.ServeHTTP(rw, req)
}
@@ -429,7 +464,18 @@ func (srv *Server) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.httpd.Shutdown(ctx)
var shutdownErr error
if srv.metricsHttpd != nil {
if err := srv.metricsHttpd.Shutdown(ctx); err != nil {
shutdownErr = fmt.Errorf("metrics HTTP server shutdown error: %w", err)
}
}
if err := srv.httpd.Shutdown(ctx); err != nil {
return errors.Join(shutdownErr, err)
}
return shutdownErr
}
// NewTemplateContext returns a new pongo2 context with some default values.
@@ -437,6 +483,8 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
return pongo2.Context{
"staticCDNHost": srv.cfg.staticCDNHost,
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
"noindex": false,
"nofollow": false,
}
}
@@ -489,10 +537,38 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
)
}
// renderOptions controls per-request rendering flags for the generic web handler.
type renderOptions struct {
noindex bool
nofollow bool
}
// webGeneric returns a handler that renders the base SPA shell with the given
// render options applied to the template context.
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
data := srv.NewTemplateContext()
data["noindex"] = o.noindex
data["nofollow"] = o.nofollow
return c.Render(http.StatusOK, "base.html", data)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := srv.NewTemplateContext()
return c.Render(http.StatusOK, "base.html", data)
return srv.webGeneric(c, renderOptions{})
}
// handler for routes that should not be indexed by search engines
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
func (srv *Server) WebGenericNoindex(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true})
}
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
// arbitrary query parameters from arbitrary third-party referrers, so we treat
// them as link-graph dead-ends in addition to noindex. Anything legitimately
// reachable from a hydrated intent page is also reachable via its canonical URL.
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
}
func (srv *Server) WebHome(c echo.Context) error {
@@ -544,49 +620,54 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
if !unauthedViewingOkay {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
data["canonicalURL"] = canonicalURL
}
data["requiresAuth"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
}
return c.Render(http.StatusOK, "post.html", data)
}
// then fetch the post thread (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
// Fetch the post thread directly. The AT-URI authority accepts either a
// handle or a DID (the appview resolves it), so we skip the separate
// ActorGetProfile call and source identity, the canonical URL, and the
// auth gate from the thread response's author view instead.
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
// up to the root in nearly all threads, letting isPartOf resolve from this
// response without a separate FeedGetPosts call.
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil {
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
// Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
// Gate before populating any post content into the template so that
// !no-unauthenticated posts never leak text/media. The appview returns the
// post (with the author self-label) to unauthed callers, so we detect the
// label here rather than via a profile fetch.
if postAuthorRequiresAuth(postView) {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
data["canonicalURL"] = canonicalURL
}
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = postView.Author.Handle
if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *postView.Author.DisplayName
}
return c.Render(http.StatusOK, "post.html", data)
}
data["postView"] = postView
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -616,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" {
jsonldURL = requestURI
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
// Best-effort: resolve a reply's thread root to its handle-form canonical
// URL for isPartOf. Prefer the root already present in the thread response
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
// chain is truncated (very deep thread) or broken by a blocked/not-found
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
// rather than point at a non-indexable page.
isPartOfURL := ""
if rootURI := threadRootURI(postView); rootURI != "" {
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
} else {
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
// Handle-form only (no DID fallback): isPartOf must match the
// root page's handle-form canonical, so an unusable handle omits
// isPartOf rather than point at a non-canonical DID-form URL.
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
}
cancel()
}
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld
} else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
@@ -673,6 +779,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data["noindex"] = true
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
code := c.Param("code")
@@ -695,7 +802,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
}
return c.Render(http.StatusOK, "chatinvite.html", data)
@@ -760,6 +867,8 @@ func (srv *Server) WebProfile(c echo.Context) error {
}
} else {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
}
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
+2 -2
View File
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+1
View File
@@ -139,6 +139,7 @@
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:logo" content="{{ favicon }}">
<meta name="twitter:site" content="@bluesky" />
+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"
}
}
}
+62 -127
View File
@@ -1,4 +1,65 @@
{
"modules/bottom-sheet/src/BottomSheetNativeComponent.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
"@typescript-eslint/require-await": {
"count": 4
}
},
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 3
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 3
}
},
"modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unsafe-call": {
"count": 9
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 9
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx": {
"@typescript-eslint/no-unsafe-call": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
@@ -19,11 +80,6 @@
"count": 1
}
},
"src/ageAssurance/util.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -106,11 +162,6 @@
"count": 1
}
},
"src/components/Autocomplete/Autocomplete.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Autocomplete/useAutocomplete/index.ts": {
"react-hooks/immutability": {
"count": 1
@@ -513,11 +564,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
@@ -525,7 +571,7 @@
},
"src/components/dialogs/LinkWarning.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
"count": 1
}
},
"src/components/dialogs/MutedWords.tsx": {
@@ -603,11 +649,6 @@
"count": 1
}
},
"src/components/dms/MessageItem.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -650,14 +691,6 @@
"count": 2
}
},
"src/components/hooks/useFullscreen.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/hooks/useLandingEntry.native.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
@@ -757,11 +790,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
@@ -865,14 +893,6 @@
"count": 1
}
},
"src/lib/hooks/useNotificationHandler.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 16
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -884,14 +904,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
@@ -1763,11 +1775,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
@@ -1854,14 +1861,6 @@
"count": 2
}
},
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/state/queries/starter-packs.ts": {
"@typescript-eslint/require-await": {
"count": 1
@@ -1903,12 +1902,6 @@
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/state/shell/color-mode.tsx": {
@@ -1966,9 +1959,6 @@
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 4
},
@@ -2064,11 +2054,6 @@
"count": 1
}
},
"src/view/com/composer/videos/pickVideo.web.ts": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2096,20 +2081,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
@@ -2135,17 +2106,6 @@
"count": 2
}
},
"src/view/com/modals/Modal.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
},
"react-hooks/refs": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2271,23 +2231,6 @@
"count": 2
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
},
"@typescript-eslint/no-floating-promises": {
"count": 4
},
"@typescript-eslint/no-misused-promises": {
"count": 3
},
"@typescript-eslint/no-unsafe-call": {
"count": 6
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 12
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -2326,9 +2269,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": {
@@ -2424,11 +2364,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
View File
@@ -7,6 +7,7 @@ export default defineConfig({
'an',
'ast',
'ca',
'cs',
'cy',
'da',
'de',
+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,20 +1,20 @@
import React from 'react'
import {createContext, useContext, useMemo} from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}>
export const Context = React.createContext({} as PortalContext)
export const Context = createContext({} as PortalContext)
Context.displayName = 'BottomSheetPortalContext'
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
const portal = useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])
+22 -12
View File
@@ -1,4 +1,14 @@
import React from 'react'
import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
type Component = React.ReactElement
@@ -13,7 +23,7 @@ type ComponentMap = {
}
export function createPortalGroup_INTERNAL() {
const Context = React.createContext<ContextType>({
const Context = createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
@@ -21,21 +31,21 @@ export function createPortalGroup_INTERNAL() {
Context.displayName = 'BottomSheetPortalContext'
function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const map = useRef<ComponentMap>({})
const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
const append = useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
map.current[id] = <Fragment key={id}>{component}</Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
const remove = useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
const contextValue = useMemo(
() => ({
outlet,
append,
@@ -50,14 +60,14 @@ export function createPortalGroup_INTERNAL() {
}
function Outlet() {
const ctx = React.useContext(Context)
const ctx = useContext(Context)
return ctx.outlet
}
function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
const {append, remove} = useContext(Context)
const id = useId()
useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
@@ -1,6 +1,6 @@
import React from 'react'
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext {
@@ -11,11 +11,10 @@ interface BackgroundNotificationPreferencesContext {
) => void
}
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
const Context = createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext,
)
export const useBackgroundNotificationPreferences = () =>
React.useContext(Context)
export const useBackgroundNotificationPreferences = () => useContext(Context)
export function BackgroundNotificationPreferencesProvider({
children,
@@ -23,18 +22,18 @@ export function BackgroundNotificationPreferencesProvider({
children: React.ReactNode
}) {
const [preferences, setPreferences] =
React.useState<BackgroundNotificationHandlerPreferences>({
useState<BackgroundNotificationHandlerPreferences>({
playSoundChat: true,
})
React.useEffect(() => {
useEffect(() => {
;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs)
})()
}, [])
const value = React.useMemo(
const value = useMemo(
() => ({
preferences,
setPref: async <
@@ -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
+17 -10
View File
@@ -1,17 +1,24 @@
import React from 'react'
import {createRef, PureComponent} 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')
interface GifViewNativeRef {
playAsync: () => Promise<void>
pauseAsync: () => Promise<void>
toggleAsync: () => Promise<void>
}
const NativeModule: {
prefetchAsync: (sources: string[]) => Promise<void>
} = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
GifViewProps & {ref: React.RefObject<GifViewNativeRef | null>}
> = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: React.RefObject<any> = React.createRef()
export class GifView extends PureComponent<GifViewProps> {
private nativeRef: React.RefObject<GifViewNativeRef | null> = createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
@@ -22,15 +29,15 @@ export class GifView extends React.PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
await this.nativeRef.current.playAsync()
await this.nativeRef.current?.playAsync()
}
async pauseAsync(): Promise<void> {
await this.nativeRef.current.pauseAsync()
await this.nativeRef.current?.pauseAsync()
}
async toggleAsync(): Promise<void> {
await this.nativeRef.current.toggleAsync()
await this.nativeRef.current?.toggleAsync()
}
render() {
@@ -1,10 +1,11 @@
import {createRef, PureComponent, type RefObject} from 'react'
import {createRef, PureComponent} from 'react'
import {StyleSheet} from 'react-native'
import {type GifViewProps} from './GifView.types'
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
private readonly videoPlayerRef: React.RefObject<HTMLVideoElement | null> =
createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
@@ -18,9 +19,9 @@ export class GifView extends PureComponent<GifViewProps> {
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
this.playAsync()
void this.playAsync()
} else {
this.pauseAsync()
void this.pauseAsync()
}
}
}
@@ -29,6 +30,7 @@ export class GifView extends PureComponent<GifViewProps> {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
}
// eslint-disable-next-line @typescript-eslint/require-await
static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web')
}
@@ -67,9 +69,21 @@ export class GifView extends PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
this.videoPlayerRef.current?.play()
try {
await this.videoPlayerRef.current?.play()
} catch (err) {
// `play()` rejects with a NotAllowedError when the browser blocks
// playback (e.g. Safari low-power mode or autoplay policy). This is
// expected and benign - the GIF simply stays paused - so swallow it
// rather than letting it surface as an unhandled rejection.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
return
}
throw err
}
}
// eslint-disable-next-line @typescript-eslint/require-await
async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause()
}
@@ -91,12 +105,12 @@ export class GifView extends PureComponent<GifViewProps> {
// When `<source>` children are present, omit `src` so the browser
// walks the source list and picks via canPlayType.
src={useSources ? undefined : source}
autoPlay={autoplay ? 'autoplay' : undefined}
autoPlay={autoplay ? true : undefined}
preload={autoplay ? 'auto' : undefined}
playsInline={true}
loop="loop"
muted="muted"
style={StyleSheet.flatten(style)}
loop={true}
muted={true}
style={StyleSheet.flatten(style) as React.CSSProperties}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
@@ -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,12 +1,12 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {useCallback} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {VisibilityViewProps} from './types'
import {type VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: Boolean
enabled: boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
@@ -21,7 +21,7 @@ export default function VisibilityView({
onChangeStatus: onChangeStatusOuter,
enabled,
}: VisibilityViewProps) {
const onChangeStatus = React.useCallback(
const onChangeStatus = useCallback(
(e: {nativeEvent: {isActive: boolean}}) => {
onChangeStatusOuter(e.nativeEvent.isActive)
},
@@ -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,3 @@
import React from 'react'
export interface VisibilityViewProps {
children: React.ReactNode
onChangeStatus: (isActive: boolean) => void
@@ -1,5 +1,4 @@
import {requireNativeView} from 'expo'
import type * as React from 'react'
import {
type EmojiPickerNativeViewProps,
+12 -11
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.123.0",
"version": "1.126.0",
"private": true,
"engines": {
"node": ">=24.15.0"
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.0",
"version": "11.9.0",
"onFail": "warn"
},
"runtime": {
@@ -59,7 +59,7 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src",
"lint": "eslint --cache --quiet src modules",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json",
@@ -90,11 +90,12 @@
"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.9",
"@atproto/syntax": "0.6.1",
"@atproto/api": "0.20.22",
"@atproto/syntax": "0.6.3",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
@@ -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.2.4",
"@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),
[]
+114 -147
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.0
version: 11.5.0
specifier: 11.9.0
version: 11.9.0
pnpm:
specifier: 11.5.0
version: 11.5.0
specifier: 11.9.0
version: 11.9.0
packages:
'@pnpm/exe@11.5.0':
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
'@pnpm/exe@11.9.0':
resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
hasBin: true
'@pnpm/linux-arm64@11.5.0':
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
'@pnpm/linux-arm64@11.9.0':
resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.0':
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
'@pnpm/linux-x64@11.9.0':
resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.0':
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
'@pnpm/linuxstatic-arm64@11.9.0':
resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.0':
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
'@pnpm/linuxstatic-x64@11.9.0':
resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.0':
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
'@pnpm/macos-arm64@11.9.0':
resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.0':
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
'@pnpm/win-arm64@11.9.0':
resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.0':
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
'@pnpm/win-x64@11.9.0':
resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.5.0:
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
pnpm@11.9.0:
resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.5.0':
'@pnpm/exe@11.9.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.0
'@pnpm/linux-x64': 11.5.0
'@pnpm/linuxstatic-arm64': 11.5.0
'@pnpm/linuxstatic-x64': 11.5.0
'@pnpm/macos-arm64': 11.5.0
'@pnpm/win-arm64': 11.5.0
'@pnpm/win-x64': 11.5.0
'@pnpm/linux-arm64': 11.9.0
'@pnpm/linux-x64': 11.9.0
'@pnpm/linuxstatic-arm64': 11.9.0
'@pnpm/linuxstatic-x64': 11.9.0
'@pnpm/macos-arm64': 11.9.0
'@pnpm/win-arm64': 11.9.0
'@pnpm/win-x64': 11.9.0
'@pnpm/linux-arm64@11.5.0':
'@pnpm/linux-arm64@11.9.0':
optional: true
'@pnpm/linux-x64@11.5.0':
'@pnpm/linux-x64@11.9.0':
optional: true
'@pnpm/linuxstatic-arm64@11.5.0':
'@pnpm/linuxstatic-arm64@11.9.0':
optional: true
'@pnpm/linuxstatic-x64@11.5.0':
'@pnpm/linuxstatic-x64@11.9.0':
optional: true
'@pnpm/macos-arm64@11.5.0':
'@pnpm/macos-arm64@11.9.0':
optional: true
'@pnpm/win-arm64@11.5.0':
'@pnpm/win-arm64@11.9.0':
optional: true
'@pnpm/win-x64@11.5.0':
'@pnpm/win-x64@11.9.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.5.0: {}
pnpm@11.9.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,11 +241,11 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.9
version: 0.20.9
specifier: 0.20.22
version: 0.20.22
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
specifier: 0.6.3
version: 0.6.3
'@bitdrift/react-native':
specifier: ^0.6.8
version: 0.6.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)
@@ -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.2.4
version: 0.2.4(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,32 +876,32 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.9':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
'@atproto/api@0.20.22':
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
'@atproto/common-web@0.5.2':
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.1':
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
'@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.0':
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
'@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.1':
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
'@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.0':
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
'@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -1659,8 +1658,8 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/peek-menu@0.2.4':
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
'@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,51 +9472,51 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.9':
'@atproto/api@0.20.22':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
'@atproto/syntax': 0.6.1
'@atproto/xrpc': 0.8.0
'@atproto/common-web': 0.5.2
'@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.2
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.0':
'@atproto/common-web@0.5.2':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-json': 0.1.0
'@atproto/syntax': 0.6.1
'@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.3
zod: 3.25.76
'@atproto/lex-data@0.1.1':
'@atproto/lex-data@0.1.3':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.0':
'@atproto/lex-json@0.1.2':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-data': 0.1.3
tslib: 2.8.1
'@atproto/lexicon@0.7.1':
'@atproto/lexicon@0.7.3':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/syntax': 0.6.1
'@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.3
multiformats: 13.4.2
zod: 3.25.76
'@atproto/syntax@0.6.1':
'@atproto/syntax@0.6.3':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.0':
'@atproto/xrpc@0.8.2':
dependencies:
'@atproto/lexicon': 0.7.1
'@atproto/lexicon': 0.7.3
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -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.2.4(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>
+17 -1
View File
@@ -134,6 +134,7 @@ import {
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {InviteScannerScreen} from '#/features/inviteFriends'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="InviteScanner"
getComponent={() => InviteScannerScreen}
options={{title: title(msg`Scan QR code`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
@@ -804,7 +810,7 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
@@ -844,6 +850,8 @@ const LINKING = {
},
} satisfies LinkingOptions<AllNavigatorParams>
let didHandlePushNotificationEntry = false
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
@@ -904,6 +912,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
function handlePushNotificationEntry() {
if (!IS_NATIVE) return
// Only consume a launching notification once per JS runtime. Account
// switches remount the entire tree (see `key={currentAccount?.did}` in
// `App.native.tsx`), which re-fires `onNavigationReady` and would
// otherwise re-process whatever `getLastNotificationResponse` still has
// cached natively (APP-2338).
if (didHandlePushNotificationEntry) return
didHandlePushNotificationEntry = true
// intent urls are handled by `useIntentHandler`
if (linkingUrl) return
+1 -1
View File
@@ -1,3 +1,3 @@
export const prefetchAgeAssuranceData = () => {}
export const prefetchAgeAssuranceServerData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {data} = useAgeAssuranceDataContext()
const {metadata} = useAgeAssuranceServerDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = data?.declaredAge !== undefined
const hasDeclaredAge = metadata?.declaredAge !== undefined
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
useEffect(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: data?.accountCreatedAt || 'unknown',
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open()
})}>
Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '}
</SimpleInlineLinkText>
</Trans>
</Admonition>
+28
View File
@@ -0,0 +1,28 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
/**
* Minimum age required to access the app at all.
*/
export const MIN_ACCESS_AGE = 13
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
+24 -22
View File
@@ -24,6 +24,7 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
import {
getBirthdateStringFromAge,
isLegacyBirthdateBug,
@@ -485,9 +486,9 @@ export function useOtherRequiredDataQuery() {
}
/**
* Helper to prefetch all age assurance data.
* Helper to prefetch all age assurance data from the server.
*/
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
@@ -496,8 +497,8 @@ export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
])
}
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
qc.removeQueries({
queryKey: createOtherRequiredDataQueryKey({did}),
@@ -505,8 +506,8 @@ export function clearAgeAssuranceDataForDid({did}: {did: string}) {
})
}
export function clearAgeAssuranceData() {
logger.debug(`clearAgeAssuranceData`)
export function clearAgeAssuranceServerDataForAll() {
logger.debug(`clearAgeAssuranceServerDataForAll`)
qc.clear()
}
@@ -514,30 +515,30 @@ export function clearAgeAssuranceData() {
* Context
*/
export type AgeAssuranceData = {
export type AgeAssuranceServerData = {
/**
* The raw config from the appview.
*/
config: AppBskyAgeassuranceDefs.Config | undefined
/**
* The raw state from the appview. Must be further processed before being useful.
*/
state: AppBskyAgeassuranceDefs.State | undefined
data:
| {
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
declaredAge: number | undefined
birthdate: string | undefined
}
| undefined
metadata: AgeAssuranceMetadata | undefined
}
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
config: undefined,
state: undefined,
data: {
metadata: {
accountCreatedAt: undefined,
declaredAge: undefined,
birthdate: undefined,
},
})
export function useAgeAssuranceDataContext() {
return useContext(AgeAssuranceDataContext)
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
}
export function AgeAssuranceDataProvider({
export function AgeAssuranceServerDataProvider({
children,
}: {
children: React.ReactNode
@@ -550,7 +551,8 @@ export function AgeAssuranceDataProvider({
() => ({
config,
state,
data: {
metadata: {
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
@@ -561,8 +563,8 @@ export function AgeAssuranceDataProvider({
[config, state, data, metadata],
)
return (
<AgeAssuranceDataContext.Provider value={ctx}>
<AgeAssuranceServerDataContext.Provider value={ctx}>
{children}
</AgeAssuranceDataContext.Provider>
</AgeAssuranceServerDataContext.Provider>
)
}
+213 -28
View File
@@ -26,35 +26,8 @@ export const deviceGeolocation: Geolocation | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 16,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
],
}
export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2000, 1, 1).toISOString(),
birthdate: new Date(2010, 12, 1).toISOString(),
}
const serverStateEnabled = false || IS_E2E
@@ -72,6 +45,218 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'GB',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'AU',
minAccessAge: 16,
rules: [
{
date: '2025-12-10T00:00:00Z',
access: 'none',
$type: ids.IfAccountNewerThan,
},
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'SD',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'WY',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'OH',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'MS',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'VA',
minAccessAge: 16,
rules: [
{
age: 16,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'TN',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'BR',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
],
}
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
+40 -49
View File
@@ -1,11 +1,12 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {createContext, useCallback, useContext, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {
AgeAssuranceDataProvider,
useAgeAssuranceDataContext,
AgeAssuranceServerDataProvider,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
@@ -14,37 +15,29 @@ import {
} from '#/ageAssurance/state'
import {
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {
isUnderAge,
maybeRestrictChatSettings,
MIN_ACCESS_AGE,
computeAgeAssuranceFlags,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
prefetchAgeAssuranceData,
prefetchAgeAssuranceServerData,
refetchServerState as refetchAgeAssuranceServerState,
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
Status: typeof AgeAssuranceStatus
state: AgeAssuranceState
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
flags: AgeAssuranceFlags
}>({
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -54,8 +47,10 @@ const AgeAssuranceStateContext = createContext<{
access: AgeAssuranceAccess.Full,
},
flags: {
isAgeRestricted: false,
adultContentDisabled: false,
chatDisabled: false,
groupChatDisabled: false,
isDeclaredUnderAdultAge: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
@@ -73,65 +68,61 @@ export function useAgeAssurance() {
export function Provider({children}: {children: React.ReactNode}) {
return (
<AgeAssuranceDataProvider>
<AgeAssuranceServerDataProvider>
<InnerProvider>
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
</InnerProvider>
</AgeAssuranceDataProvider>
</AgeAssuranceServerDataProvider>
)
}
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
const {metadata} = useAgeAssuranceServerDataContext()
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
if (isAgeRestricted) {
void getAndRegisterPushToken({isAgeRestricted})
maybeRestrictChatSettings({agent})
const flags = computeAgeAssuranceFlags({
state: s,
regionConfig,
metadata,
})
if (flags.isAgeRestricted) {
void getAndRegisterPushToken({
isAgeRestricted: true,
})
}
if (flags.chatDisabled || flags.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
},
[agent, getAndRegisterPushToken],
[agent, getAndRegisterPushToken, regionConfig, metadata],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
useEffect(() => {
logger.debug(`useAgeAssuranceState`, {state})
}, [state])
return (
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isDeclaredUnderAdultAge = data?.birthdate
? isUnderAge(data.birthdate, 18)
: true
const isOverRegionMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, config.minAccessAge)
: false
const isOverAppMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
const res = {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state,
flags: {
adultContentDisabled,
chatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
},
flags: computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}),
}
}, [state, data, config])}>
logger.debug(`useAgeAssurance`, res)
return res
}, [state, metadata, regionConfig])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+51 -29
View File
@@ -1,24 +1,30 @@
import {useEffect, useMemo, useState} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
type AgeAssuranceData,
getConfigFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceDataContext,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
type AgeAssuranceMetadata,
type AgeAssuranceState,
AgeAssuranceStatus,
parseAccessFromString,
parseStatusFromString,
} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {
computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -27,18 +33,18 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
export function computeAgeAssuranceState({
function computeAgeAssuranceState({
hasSession,
config,
geolocation,
config,
state,
data,
metadata,
}: {
hasSession: boolean
config: AgeAssuranceData['config']
geolocation: Geolocation
state: AgeAssuranceData['state']
data: AgeAssuranceData['data']
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -88,7 +94,10 @@ export function computeAgeAssuranceState({
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, data)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
@@ -100,10 +109,10 @@ export function computeAgeAssuranceState({
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('debug useAgeAssuranceState', {
logger.debug('computeAgeAssuranceState', {
region,
state,
data,
metadata,
computed,
})
return computed
@@ -113,38 +122,51 @@ export function computeAgeAssuranceState({
* This is a last-ditch helper for out-of-band reads of the AA state, such as
* during account creation. Don't use it for anything else.
*/
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const data = getOtherRequiredDataFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !data) {
if (!geolocation || !config || !state || !requiredData) {
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
state: {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
},
}
}
return computeAgeAssuranceState({
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const metadata: AgeAssuranceMetadata = {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: requiredData?.birthdate
? getAge(new Date(requiredData.birthdate))
: undefined,
birthdate: requiredData?.birthdate,
}
const computed = computeAgeAssuranceState({
hasSession: true,
config,
geolocation,
state: state.state,
data: {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
: undefined,
birthdate: data?.birthdate,
},
metadata,
})
return {
state: computed,
flags: computeAgeAssuranceFlags({
state: computed,
regionConfig: region,
metadata,
}),
}
}
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, data} = useAgeAssuranceDataContext()
const {config, state, metadata} = useAgeAssuranceServerDataContext()
return useMemo(
() =>
@@ -153,9 +175,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
config,
geolocation,
state,
data,
metadata,
}),
[hasSession, geolocation, config, state, data],
[hasSession, geolocation, config, state, metadata],
)
}
+18
View File
@@ -1,3 +1,5 @@
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
export enum AgeAssuranceAccess {
@@ -14,6 +16,12 @@ export enum AgeAssuranceStatus {
Blocked = 'blocked',
}
export type AgeAssuranceMetadata = Parameters<
typeof computeAgeAssuranceRegionAccess
>[1] & {
birthdate: string | undefined
}
export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
@@ -21,6 +29,16 @@ export type AgeAssuranceState = {
error?: 'config' // maybe other specific cases in the future
}
export type AgeAssuranceFlags = {
isAgeRestricted: boolean
adultContentDisabled: boolean
chatDisabled: boolean
groupChatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
export function parseStatusFromString(raw: string) {
switch (raw) {
case 'unknown':
@@ -1,14 +1,14 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation} from '#/geolocation'
export function useComputeAgeAssuranceRegionAccess() {
const {config, data} = useAgeAssuranceDataContext()
const {config, metadata} = useAgeAssuranceServerDataContext()
return useCallback(
(geolocation: Geolocation) => {
if (!config) {
@@ -19,11 +19,14 @@ export function useComputeAgeAssuranceRegionAccess() {
config,
geolocation,
)
const result = computeAgeAssuranceRegionAccess(region, data)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
return result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full
},
[config, data],
[config, metadata],
)
}
+42 -39
View File
@@ -1,41 +1,22 @@
import {useMemo} from 'react'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AtpAgent,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
export const MIN_ACCESS_AGE = 13
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
/**
* Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found.
@@ -62,7 +43,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
const {config} = useAgeAssuranceDataContext()
const {config} = useAgeAssuranceServerDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
@@ -116,15 +97,37 @@ export const makeAgeRestrictedModerationPrefs = (
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
/**
* Checks our cache of the actor's chat declaration record, and if it's not
* already restricted, restricts it.
*/
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
const data = getOtherRequiredDataFromCache({did})
// ...update the chat setting record if allowIncoming is not already 'none'.
if (data?.actorDeclaration?.allowIncoming === 'none') return
restrictChatSettings({agent, did})
export function computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}: {
state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata
}): AgeAssuranceFlags {
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
const chatDisabled = isAgeRestricted
const isDeclaredUnderAdultAge = metadata?.declaredAge
? metadata.declaredAge < 18
: true
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
const isOverRegionMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= regionConfig.minAccessAge
: false
const isOverAppMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= MIN_ACCESS_AGE
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
isAgeRestricted,
adultContentDisabled,
chatDisabled,
groupChatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
}
}
+4 -3
View File
@@ -9,11 +9,12 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
GroupChatsDisable = 'group_chats:disable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
AATest = 'aa-test',
}

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