Compare commits

...

187 Commits

Author SHA1 Message Date
Samuel Newman 604ab79e74 specify exact eas-cli version to install 2026-05-22 14:54:56 +03:00
Samuel Newman ac69cc23dd rm newline 2026-05-22 14:30:00 +03:00
Samuel Newman 195c16d74b set devEngines field properly 2026-05-22 14:25:48 +03:00
Samuel Newman ffa493f8db loosen eas cli version in config 2026-05-22 14:11:28 +03:00
Samuel Newman 9558f172ff rm expo-cli 2026-05-22 14:10:07 +03:00
Samuel Newman 1772135ad0 attempt to remove eas-cli from devDeps 2026-05-22 14:05:46 +03:00
Samuel Newman 2f4049e6e0 Bump atproto to latest (#10586) 2026-05-22 00:25:25 -07:00
DS Boyce 53df5e62bb Wrap group chat names when appropriate (#10583) 2026-05-21 23:47:41 -07:00
DS Boyce 4c73b171d6 Reduce the number of join requests per page to 20 (#10578) 2026-05-21 23:39:11 -07:00
pfrazee 6f0912346c Nightly source-language update 2026-05-22 03:19:03 +00:00
Samuel Newman 6fd81fe5dc ESM atproto packages (#10543) 2026-05-21 16:41:04 -05:00
DS Boyce e1e5f28e98 Display badges next to chat inviter's display name (#10451) 2026-05-21 12:54:37 -07:00
DS Boyce f653faab94 Fix word wrapping in image menu on Android (#10577) 2026-05-21 10:32:35 -07:00
Samuel Newman 7da3f385a8 Fix stuck tooltip bubble on Safari (#10560)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:49:58 -07:00
RetroSunstar 0858010a5d Fix Image Options not opening on web (#10544) 2026-05-21 09:32:29 -07:00
Samuel Newman 901b1a3a9e [Chat] Remove ChatEmptyPill (#10575) 2026-05-21 09:25:05 -07:00
Samuel Newman c0f7ffa728 [Chat] Make group header a link, fix behaviour with long text (#10572) 2026-05-21 09:21:06 -07:00
pfrazee 1b546c54db Nightly source-language update 2026-05-21 03:19:49 +00:00
Samuel Newman 7fe2f892f6 Revert "Skip installing expo cli in CI (#10566)" (#10568) 2026-05-20 21:30:53 +03:00
Samuel Newman b73f9110e5 Skip installing expo cli in CI (#10566) 2026-05-20 09:50:44 -07:00
Samuel Newman c4ecb007f3 [Chat] New post attachment style in composer (#10563) 2026-05-20 09:50:34 -07:00
Samuel Newman 3d4ba946b3 [pnpm] Fix CI attempt 5 (#10567) 2026-05-20 09:33:02 -07:00
Samuel Newman a2417e043e [Chat] Add embeds to the optimistic message (#10564) 2026-05-20 09:30:04 -07:00
Samuel Newman 21446b43af [Chat] MessageItem tweaks (#10562) 2026-05-20 09:26:24 -07:00
Samuel Newman 4908816253 [pnpm] Fix CI attempt 4 (#10565) 2026-05-20 08:58:55 -07:00
Samuel Newman c1817d3425 [Chat] Handle new chat notification reasons (#10530)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:20:35 -07:00
DS Boyce 9cad883ab8 Update appearance of chat list item states (#10556)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-05-20 07:17:25 -07:00
Samuel Newman 46a4f6cbee Run prettier (#10559) 2026-05-20 04:47:59 -07:00
Samuel Newman 5088f00c35 Fix up prettier config, apply to more files (#10542) 2026-05-20 03:53:19 -07:00
DS Boyce 70a1a665ed Fix eas invocation for GitHub Actions (#10555) 2026-05-20 03:52:35 -07:00
DS Boyce 2f80d983d3 Update Dockerfile.embedr to work with pnpm (#10554) 2026-05-20 12:03:37 +03:00
pfrazee 20f1c60782 Nightly source-language update 2026-05-20 03:19:04 +00:00
DS Boyce 34ae819aab Fix some issues with message spacing (#10553) 2026-05-19 13:06:21 -07:00
Samuel Newman 7b6a81ac23 Remove unread state from locked chats (#10552) 2026-05-19 22:22:27 +03:00
Samuel Newman 8cea36f3c9 [pnpm] Migrate bskyembed (#10482) 2026-05-19 11:51:10 -07:00
Samuel Newman 09253457f9 Gate group chat invites on allowGroupInvites (#10546)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:47:54 -07:00
DS Boyce 12ab1dd2fd Fix CI by restoring changes from #10538 (#10550) 2026-05-19 11:46:07 -07:00
DS Boyce b4054bf2a7 Update GitHub Actions to Node.js v24-compatible versions (#10539) 2026-05-19 11:06:46 -07:00
Eric Bailey 2cee865f4a Update husky (#10549) 2026-05-19 09:57:11 -05:00
Samuel Newman 683ae7610f [pnpm] Install node version via devEngines.runtime (#10503) 2026-05-19 07:37:55 -07:00
Samuel Newman 366cf7e040 Rebalance splitview columns (#10512) 2026-05-19 07:33:10 -07:00
dependabot[bot] 8e8dc7561f Bump ws from 8.20.0 to 8.20.1 (#10537)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 03:27:52 -07:00
DS Boyce af6041d7bc Fix CI attempt 3 (#10538) 2026-05-19 08:23:18 +03:00
pfrazee b795757a86 Nightly source-language update 2026-05-19 03:18:28 +00:00
DS Boyce bc148ede43 Tweak reaction size and padding (#10534) 2026-05-18 14:37:29 -07:00
DS Boyce f9f11c802b Exclude @atproto and @bsky.app packages from minimum age exclusion (#10536) 2026-05-18 14:05:13 -07:00
Samuel Newman 632ce5cc37 [Chat] set reaction count font weight to medium (#10532) 2026-05-18 23:54:48 +03:00
DS Boyce 52342f1cda Fix pnpm lockfile integrity check (#10529) 2026-05-18 13:52:43 -07:00
Samuel Newman 79872b8be5 [pnpm] Fix CI attempt 2 (#10533) 2026-05-18 23:33:54 +03:00
Samuel Newman 5b622079b6 [pnpm] Fix CI (#10531) 2026-05-18 13:22:26 -07:00
Samuel Newman 0dc062294f Move headerMode out of global shell state (#10527) 2026-05-18 10:53:12 -07:00
Samuel Newman e40c9bcf98 [Chat] Persist left column scroll in splitview (#10513) 2026-05-18 10:47:41 -07:00
Samuel Newman b1ce519361 [Chat] adjust list insets on web (#10524) 2026-05-18 10:00:20 -07:00
Samuel Newman ff731849b0 Migrate from Yarn 1 to pnpm (#10465)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-05-18 09:51:04 -07:00
surfdude29 b7c2c2f183 Add missing space to string (#10511) 2026-05-18 09:35:11 -07:00
Samuel Newman e58a09e0ca Fix automatic footer compensation in Layout.Content (#10526) 2026-05-18 04:57:06 -07:00
pfrazee 45597de6e0 Nightly source-language update 2026-05-16 03:16:55 +00:00
DS Boyce 5da73c2d67 Add per-file codemod for updating useLingui to v5 (#10505) 2026-05-15 13:28:40 -07:00
DS Boyce 2bf6bdb6bc Update chat presentation (#10444) 2026-05-15 13:25:47 -07:00
Samuel Newman 0eb382da03 [Chat] Split view layout for web (#10258) 2026-05-15 12:46:53 -07:00
DS Boyce 5b7bc56a12 Address any types in List component (#10504) 2026-05-15 12:42:32 -07:00
DS Boyce cc7d8296f0 Fix unnecessary scrollbars on web (#10502) 2026-05-15 11:56:19 -07:00
Samuel Newman 9d98890fd2 Fix reply failures caused by fetching posts from PDS (#9925) 2026-05-15 11:43:07 -07:00
DS Boyce e0cb1c3413 Remove unused code for unsupported 'invited' status (#10492) 2026-05-15 09:29:09 -07:00
DS Boyce f963c90eb5 Update appearance of chat disabled message (#10488) 2026-05-15 09:28:48 -07:00
DS Boyce 8756c6c0ac Update message context menu styles (#10487) 2026-05-15 09:28:26 -07:00
Eric Bailey b41dbec48f Remove KLIPY from external media settings (#10496) 2026-05-15 10:54:42 -05:00
pfrazee e52b7df39b Nightly source-language update 2026-05-15 03:19:40 +00:00
DS Boyce 0b63ee40c7 Don't show Follow link if user is blocked (#10491) 2026-05-14 10:36:59 -07:00
DS Boyce e18b76d897 Display icon on chat list for system messages (#10493) 2026-05-14 10:28:53 -07:00
DS Boyce c81174e4f0 Fix labels in invite link dialog (#10472) 2026-05-14 05:30:07 -07:00
pfrazee da44df9124 Nightly source-language update 2026-05-14 03:17:55 +00:00
DS Boyce a07e364a87 Use i18n.date for date dividers (#10486) 2026-05-13 15:09:10 -07:00
DS Boyce b41d0ad97d Improve birthdate handling (#10477) 2026-05-13 13:52:59 -07:00
Eric Bailey d20df78f7e Stop writing associatedRecord to embeds, may need to adjust schema (#10489) 2026-05-13 15:52:43 -05:00
DS Boyce 9e55bb71ef Add export data dialog to chat settings (#10478) 2026-05-13 12:30:00 -07:00
Samuel Newman 34b3c52729 Keep screen awake when playing youtube video (#10479) 2026-05-13 08:51:53 -07:00
rafael e9ad74bb01 fix did query string arg in ogcard (#10484) 2026-05-13 12:28:20 -03:00
Fahad Ahmed a7439eb459 Fix stale display name for renamed pinned lists (#10466) 2026-05-13 08:19:29 -07:00
pfrazee e776c37d17 Nightly source-language update 2026-05-13 03:18:59 +00:00
Austin McKinley ddcf7d74e9 ci: build link image on push to main (#10471)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:07:22 -07:00
DS Boyce a8debddd08 Update chat menu styles and options (#10455) 2026-05-12 12:53:47 -07:00
Austin McKinley f0fec7a9da ci: build ogcard image on push to main (#10469)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:45:34 -07:00
Samuel Newman f527b35244 Add avatar-bubbles endpoint to bskyogcard (#10327)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: rafaeleyng <rafaeleyng@gmail.com>
2026-05-12 13:54:00 -03:00
Spence Pope a03cbd8b1a [APP-2170] Render GIFs through ConstrainedImage (#10453)
Co-authored-by: vineyardbovines <spencer@thepope.dev>
2026-05-12 11:17:46 -04:00
dependabot[bot] 859e172651 Bump axios from 1.13.6 to 1.16.0 in /dev-env (#10461)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 23:53:37 -07:00
pfrazee c5600c5a17 Nightly source-language update 2026-05-12 03:19:10 +00:00
DS Boyce bfd24f6d9e Update appearance of new messages pill (#10442) 2026-05-11 09:40:56 -07:00
Samuel Newman b2335dcdb1 Update react-native-keyboard-controller (#10329) 2026-05-11 08:54:01 -07:00
Spence Pope 9b5be84c5a Add region notice to age assurance card (#10439) 2026-05-11 08:27:24 -04:00
Jim Calabro 28428a5750 Add Prometheus Metrics to Bskyweb (#10370) 2026-05-11 05:41:44 -04:00
pfrazee 10b8f694ef Nightly source-language update 2026-05-09 03:17:25 +00:00
DS Boyce 1609778ff6 Update chat settings (#10449) 2026-05-08 15:49:44 -07:00
DS Boyce 75d8fb3dbd Group runs of system messages under a single toggle (#10388) 2026-05-08 15:24:34 -07:00
Spence Pope 8b9361016a [APP-2146] Update GIF content filtering and [APP-2152] harden web GIFs (#10443)
Co-authored-by: vineyardbovines <spencer@thepope.dev>
2026-05-08 15:34:07 -04:00
DS Boyce a889a1b081 Update presentation of chat requests (#10417) 2026-05-08 12:05:05 -07:00
DS Boyce c8e4815837 Update chat requests header (#10446) 2026-05-08 11:53:02 -07:00
DS Boyce 5640aed7e3 Preserve memoization of message list (#10434) 2026-05-08 10:54:08 -07:00
Samuel Newman 92fdb6630d Align composer video preview with in-feed embed (#10447)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:37:07 -07:00
Samuel Newman 92199774d5 [Chat] Set the default listConvos limit to 10 (#10448) 2026-05-08 10:29:56 -07:00
pfrazee e4f35fae08 Nightly source-language update 2026-05-08 03:15:18 +00:00
Eric Bailey fa8c2224b7 Add associatedRecord to external embeds, if available (#10445) 2026-05-07 19:08:29 -05:00
DS Boyce 9a2cabf64c Update context menu presentation (#10426) 2026-05-07 13:24:55 -07:00
DS Boyce 10cb0dbf21 Improve initial loading state for chat settings (#10424) 2026-05-07 12:00:42 -07:00
DS Boyce eddbf6ba9f Update presentation of settings menus (#10425) 2026-05-07 12:00:21 -07:00
DS Boyce 2907ff0cd8 Fix issue where last RecentProfileItem can't be removed (#10441) 2026-05-07 10:07:08 -07:00
pfrazee 1aeda24975 Nightly source-language update 2026-05-07 03:18:24 +00:00
Spence Pope 445aef8fec update locale in klipy api (#10432) 2026-05-06 18:16:06 -04:00
DS Boyce c91eae3720 Show message when chat is locked or ended (#10413) 2026-05-06 10:42:54 -07:00
DS Boyce 945d8b229d Reduce intensity of drop shadow on chat reactions (#10414) 2026-05-06 10:06:42 -07:00
Samuel Newman 7c22abcb66 [Chat] Add emoji prop to message displayname (#10431) 2026-05-06 09:39:11 -07:00
DS Boyce 26ca0a671b Improve localization and accessibility for the reactions dialog (#10409) 2026-05-06 09:36:57 -07:00
DS Boyce 276143c92b Display chat message underneath embed, not above (#10423) 2026-05-06 09:35:48 -07:00
Samuel Newman 0b6ae1cc21 Fix picking offloaded videos for IOS 26 (#10428) 2026-05-06 06:40:22 -07:00
Samuel Newman a7ed3ee3cc [Chat] Fix assumption that every chat has an owner (#10373) 2026-05-06 06:11:07 -07:00
pfrazee 2aa544fade Nightly source-language update 2026-05-06 03:17:44 +00:00
Eric Bailey 8e06c9fbb7 Add ability for users to send error reports (#10427) 2026-05-05 17:21:42 -05:00
Eric Bailey 9bf06a80a5 Add push noty script and docs (#10380) 2026-05-05 14:34:55 -05:00
DS Boyce 6f4ae14f36 Update to Node.js v24 (#10419) 2026-05-05 11:51:42 -07:00
Eric Bailey cc5b215863 Bump mod SDK and add more details to mod info dialog (#10420) 2026-05-05 13:28:06 -05:00
DS Boyce a24d30ab47 Add ability to follow from chat settings (#10415) 2026-05-05 09:51:40 -07:00
DS Boyce c21138ba63 Differentiate reactions that include the viewer (#10416) 2026-05-05 09:29:52 -07:00
Spence Pope a7fabd9e0d Refresh lightbox designs (#10330)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:29 -04:00
Spence Pope 48ea9e0b96 [APP-2067] Rebuild GIF Dialog (#10261)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:06:18 -04:00
pfrazee 3a997031e0 Nightly source-language update 2026-05-05 03:17:16 +00:00
DS Boyce 482fb77e36 Re-enable group invite link management (#10407) 2026-05-04 15:44:27 -07:00
DS Boyce e34080c4a0 Fix some minor issues with the invite link dialog (#10408) 2026-05-04 14:44:31 -07:00
DS Boyce 923d06229c Don't sqaure corners around emoji-only messages (#10411) 2026-05-04 14:44:08 -07:00
Eric Bailey 098ffcad3f Bump tapper/sift (#10412) 2026-05-04 16:10:41 -05:00
Samuel Newman 1b3b8e493a [Chat] Defer message send until after textinput resizes (#10405) 2026-05-04 14:05:15 -07:00
Samuel Newman dc9b9a887b [Chat] Reenable the gc gate (#10406) 2026-05-04 10:24:25 -07:00
ave 2c5d7f20b0 Remove unnecessary 90ms latency from tap events on android (#10392) 2026-05-04 05:39:26 -07:00
pfrazee 85c7cefd7c Nightly source-language update 2026-04-30 03:17:28 +00:00
DS Boyce f1764f1d61 Move useListConvoMembersQuery inside AddMembersFlow (#10386) 2026-04-29 16:49:47 +01:00
Samuel Newman 736c9625ed Fix end of feed border color (#10393) 2026-04-29 07:19:23 -07:00
DS Boyce b97cd413e3 Update CLAUDE.md with additional context and instructions (#10385) 2026-04-29 03:53:07 -07:00
Evan 8bfce09628 Fix search query autocomplete lagging behind (#10381) 2026-04-28 06:22:48 -07:00
DS Boyce 28327459e4 Enable @typescript-eslint/no-explicit-any (#10383) 2026-04-28 10:59:04 +01:00
Samuel Newman 179f3c82e7 Use tsgo for typechecking (#9474) 2026-04-28 01:44:15 -07:00
Samuel Newman 46b8a5819b Harden iOS build extraction in bundle-deploy workflow (#10372)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 10:45:15 +03:00
pfrazee 8dacc91908 Nightly source-language update 2026-04-25 03:12:59 +00:00
DS Boyce 5bf38e3721 Fix avatar bubble sizes when there's only one chat member (#10369) 2026-04-24 13:59:07 -07:00
Samuel Newman 7eec65ac78 [Chat] Temporarily turn off gate (#10368) 2026-04-24 22:45:22 +03:00
Eric Bailey cdb8d4bfb8 Group Clops Feature Branch (#10360)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:38:50 -07:00
Samuel Newman e832791367 [Chat] Improve gate (#10359) 2026-04-24 08:48:57 -05:00
Samuel Newman deb3f26d3e Fix send button disabled when sharing post via DM without text (#10355)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 05:50:08 -05:00
pfrazee a90bb66d67 Nightly source-language update 2026-04-24 03:15:36 +00:00
Eric Bailey 9762a6eef4 See if we can harden the build extraction steps (#10348) 2026-04-23 17:02:17 -05:00
Samuel Newman 7b490d8bcd Only lock viewport zoom while text inputs are focused (#10340)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-23 10:42:05 -07:00
DS Boyce 6fc31eeb48 Clean up minor issues in MessgeItem (#10338) 2026-04-23 09:30:50 -07:00
pfrazee bf371d7ecc Nightly source-language update 2026-04-23 03:15:31 +00:00
DS Boyce 9ba11baab7 Display actual number of group clip clop requests (#10333) 2026-04-22 12:31:38 -07:00
DS Boyce 8d6d4f9850 Simplify chat margins (#10334) 2026-04-22 12:31:08 -07:00
Samuel Newman 18143d051b Bump version from 1.121.0 to 1.122.0 (#10336) 2026-04-22 22:04:51 +03:00
DS Boyce 38c8adcc27 Fix some minor chat issues (#10332) 2026-04-22 10:18:01 -07:00
DS Boyce dcc06a90a0 Display sender name in last message for group clip clops (#10320)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-22 10:03:53 -07:00
Eric Bailey c7e9efbf99 Prep release 1.121.0 (#10331) 2026-04-22 11:04:03 -05:00
Samuel Newman 1c38665d4c Replace lande with native language detection (#9974)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-22 10:26:14 -05:00
pfrazee 5d40532aa9 Nightly source-language update 2026-04-22 03:14:44 +00:00
Samuel Newman abe0ca521d Fix slow re-renders when toggling content languages (#10128)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:16:26 -05:00
DS Boyce 07344f70fc Fix missing recId values for suggested user events (#10291)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-21 15:59:13 -05:00
Samuel Newman bfdaab0a14 [Chat] Workaround for autocomplete suggestion bug on iOS (#10321) 2026-04-21 15:56:07 -05:00
Samuel Newman 3153ea4302 Refactor minimal shell mode to refcounting (#10319) 2026-04-21 13:04:02 -07:00
Samuel Newman 6d53459e92 [Chat] Gate groupchat screen (#10316) 2026-04-21 11:21:19 -07:00
DS Boyce 2ab1e2c9e9 Display system messages in chats (#10312)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-21 10:43:22 -07:00
Samuel Newman 0df1d6f53e [Chat] Message context menu fixes (#10314) 2026-04-21 09:24:46 -07:00
Samuel Newman 18052f08e0 Update claude workflow to 4.7 (#10315) 2026-04-21 06:19:52 -07:00
Spence Pope 3931b90818 don't screenshot dictation placeholders as pasted images (#10313) 2026-04-20 23:35:22 -07:00
pfrazee 985129dd34 Nightly source-language update 2026-04-21 03:14:34 +00:00
DS Boyce 8c2e4c6fad Write chat declaration record in response to different events (#10216)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-20 15:39:12 -05:00
DS Boyce d58ff89441 Require age assurance to access chat settings (#10053) 2026-04-20 15:34:32 -05:00
Samuel Newman 014ffac903 Delete "non-standard" styles from pal (#10279) 2026-04-20 11:34:25 -07:00
Samuel Newman ae0c2e8697 [Web] Scale animation when clicking on images (#10305) 2026-04-20 13:09:35 -05:00
Samuel Newman b8cabfaae6 Skip empty posts when publishing threads (#10307)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:33:38 -07:00
Spence Pope 444c5787c5 [APP-2037] add bsky video (#10281)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-20 07:21:25 -07:00
surfdude29 2798e98c9c Add context for translators and tweak strings (#10280) 2026-04-20 07:06:18 -07:00
Samuel Newman 9778a3da16 Remove decelerationRate from embed gallery FlatList (#10298)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 06:54:25 -07:00
Samuel Newman 52b8201d2f Add READMEs to modules (#10306) 2026-04-20 06:53:04 -07:00
Thomas May dddc022747 nit: fix perf regression in hotkeys (#10302)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-20 06:15:05 -07:00
pfrazee 5df51bdea7 Nightly source-language update 2026-04-19 03:14:38 +00:00
Samuel Newman bc3672ceeb [Chat] Remove convoState dependency from header (#10293) 2026-04-18 12:58:16 -07:00
pfrazee 35411e88c9 Nightly source-language update 2026-04-18 03:13:09 +00:00
DS Boyce 935347c73d Use convo view instead of convo state for driving the UI (#10290) 2026-04-17 15:23:44 -07:00
DS Boyce feebc6a98b Fix layout issue with avatar bubbles (#10289) 2026-04-17 13:50:41 -07:00
Eric Bailey 587dc8dfe8 Squash carousel in chat messages, fix rounding (#10286) 2026-04-17 14:24:42 -05:00
Samuel Newman 43108533eb [Chat] Fix embed offset (#10285) 2026-04-17 11:44:48 -07:00
DS Boyce adca192f3a Add ability to edit group clip clop name (#10275) 2026-04-17 11:44:34 -07:00
499 changed files with 134153 additions and 73533 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpm typecheck *)",
"Bash(pnpm lint *)",
"Bash(pnpm test *)"
]
}
}
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,11 +1,9 @@
name: build-and-push-link-aws
on:
workflow_dispatch:
pull_request:
paths:
- "bskylink/**"
- "Dockerfile.bskylink"
- ".github/workflows/build-and-push-link-aws.yaml"
push:
branches:
- main
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -24,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,11 +1,9 @@
name: build-and-push-ogcard-aws
on:
workflow_dispatch:
pull_request:
paths:
- "bskyogcard/**"
- "Dockerfile.bskyogcard"
- ".github/workflows/build-and-push-ogcard-aws.yaml"
push:
branches:
- main
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -24,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
+35 -42
View File
@@ -28,39 +28,38 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
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
@@ -88,8 +87,8 @@ jobs:
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 }}
yarn use-build-number-with-bump
eas build -p android
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile $PROFILE
--local --output build.aab --non-interactive
@@ -106,7 +105,7 @@ jobs:
- name: 🚀 Upload Production Artifact
id: upload-artifact-production
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 6
@@ -116,7 +115,7 @@ jobs:
- name: 🚀 Upload Testflight Artifact
id: upload-artifact-testflight
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 6
@@ -129,27 +128,23 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
uses: slackapi/slack-github-action@v2.1.1
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 }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
{"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 }}```"}
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v1.25.0
uses: slackapi/slack-github-action@v2.1.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 }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
- name: 🧹 Clear Metro cache
if: ${{ inputs.profile == 'production' }}
@@ -162,15 +157,15 @@ jobs:
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 }}
yarn use-build-number-with-bump
eas build -p android
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile production-apk
--local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 6
@@ -179,19 +174,17 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
uses: slackapi/slack-github-action@v2.1.1
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 }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
{"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 }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
uses: actions/cache@v5
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
+49 -29
View File
@@ -28,32 +28,31 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: 🔨 Setup EAS
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.4"
@@ -64,16 +63,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v4
uses: actions/cache@v5
id: pods-cache
with:
path: ./ios/Pods
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
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
@@ -100,8 +99,8 @@ jobs:
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 }}
yarn use-build-number-with-bump
eas build -p ios
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile $PROFILE
--local --output build.tar.gz --non-interactive
@@ -109,23 +108,46 @@ jobs:
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
mkdir ios-build
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: 📚 Get version from package.json
id: get-build-info
@@ -133,19 +155,17 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
uses: slackapi/slack-github-action@v2.1.1
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 }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
{"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 }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
uses: actions/cache@v5
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
+90 -57
View File
@@ -48,7 +48,7 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -56,11 +56,13 @@ jobs:
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
@@ -70,35 +72,28 @@ jobs:
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
run: pnpm lint
- name: Prettier check
run: yarn prettier --check .
run: pnpm prettier --check .
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
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
- name: Type check
run: yarn typecheck
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }}
uses: dcarbone/install-jq-action@v2
@@ -130,11 +125,11 @@ jobs:
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 }}
yarn export
pnpm export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: yarn use-build-number bash scripts/bundleUpdate.sh
run: pnpm use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
@@ -142,7 +137,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
uses: actions/cache@v5
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
path: most-recent-testflight-commit.txt
@@ -172,28 +167,27 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
run: pnpm install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
@@ -205,16 +199,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v4
uses: actions/cache@v5
id: pods-cache
with:
path: ./ios/Pods
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: yarn intl:build
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -236,17 +230,59 @@ jobs:
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 }}
yarn use-build-number-with-bump
eas build -p ios
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile testflight
--local --output build.ipa --non-interactive
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
uses: actions/cache@v5
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
@@ -278,36 +314,35 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: 🔤 Compile translations
run: yarn intl:build
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -330,8 +365,8 @@ jobs:
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 }}
yarn use-build-number-with-bump
eas build -p android
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile testflight-android
--local --output build.apk --non-interactive
@@ -343,7 +378,7 @@ jobs:
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
retention-days: 30
compression-level: 0
@@ -351,19 +386,17 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v1.25.0
uses: slackapi/slack-github-action@v2.1.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.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
uses: actions/cache@v5
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
--model claude-opus-4-7
+41 -16
View File
@@ -15,47 +15,72 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Verify Node version pins match .nvmrc
run: |
set -euo pipefail
expected=$(tr -d '[:space:]' < .nvmrc)
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z — service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z — Go images that nvm-install Node for the JS build stage
for f in Dockerfile Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# eas.json "node": "X.Y.Z"
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
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: yarn --frozen-lockfile
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
run: pnpm lint
- name: Prettier check
run: yarn prettier --check .
run: pnpm prettier --check .
- name: Check & compile i18n
run: yarn intl:build
run: pnpm intl:build
- name: Type check
run: yarn typecheck
run: pnpm typecheck
testing:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: yarn --frozen-lockfile
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: yarn intl:build
run: pnpm intl:build
- name: Run tests
run: |
NODE_ENV=test yarn test --forceExit
NODE_ENV=test pnpm test --forceExit
@@ -16,22 +16,23 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: yarn --frozen-lockfile
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: yarn intl:extract
run: pnpm intl:extract
- name: Create commit
uses: stefanzweifel/git-auto-commit-action@v5
with:
+12 -16
View File
@@ -113,44 +113,40 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: Install dependencies
run: yarn install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
run: pnpm lint
- name: 🔤 Compile translations
run: yarn intl:build 2>&1 | tee i18n.log
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
- name: Type check
run: yarn typecheck
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
expo-version: latest
eas-version: latest
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
@@ -175,10 +171,10 @@ jobs:
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 }}
yarn export
pnpm export
- name: 📦 Package Bundle and 🚀 Deploy
run: yarn use-build-number bash scripts/bundleUpdate.sh
run: pnpm use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
+15 -11
View File
@@ -24,15 +24,17 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: Ensure tracking relevant branches and checkout base
env:
@@ -56,17 +58,17 @@ jobs:
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit $HEAD_REF
yarn install
yarn intl:build
pnpm install
pnpm intl:build
- name: 🔦 Generate stats file for PR
run: |
yarn generate-webpack-stats-file
pnpm generate-webpack-stats-file
mv stats.json ../stats-new.json
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
@@ -80,9 +82,9 @@ jobs:
- name: 🔦 Generate stats file from base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
yarn install
yarn intl:build
yarn generate-webpack-stats-file
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
@@ -108,7 +110,7 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 100
@@ -116,11 +118,13 @@ jobs:
run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request'
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
cache: pnpm
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Generate GitHub App Token
@@ -7,12 +7,12 @@ concurrency:
cancel-in-progress: true
jobs:
verify-yarn-lock:
name: No manual yarn.lock edits
verify-pnpm-lock:
name: No manual pnpm-lock.yaml edits
runs-on: ubuntu-latest
steps:
- name: Check out PR HEAD
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -21,28 +21,30 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Reset yarn.lock to base
- name: Reset pnpm-lock.yaml to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: Yarn install
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: yarn install --ignore-scripts
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Verify yarn.lock
- name: Verify pnpm-lock.yaml
run: |
git diff --quiet --exit-code || {
echo '::error::`yarn.lock` does not match what Yarn would generate given the base `yarn.lock` and the head `package.json`.'
echo '::error::`pnpm-lock.yaml` does not match what pnpm would generate given the base `pnpm-lock.yaml` and the head `package.json`.'
echo '::error:: - If this is intentional, you can ignore this check.'
echo '::error:: - If this is unintentional, apply the following diff:'
git --no-pager diff
+6
View File
@@ -86,6 +86,9 @@ stats.json
# VSCode
.vscode
# Zed
.zed
# gitignore and github actions
!.gitignore
!.github
@@ -127,3 +130,6 @@ bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
+1 -4
View File
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged --concurrent false
pnpm exec lint-staged --concurrent false
+236
View File
@@ -0,0 +1,236 @@
/**
* Codemod to update Lingui's useLingui() hook to use v5.
*
* Before:
* import {msg} from '@lingui/core/macro'
* import {useLingui} from '@lingui/react'
* const {_} = useLingui()
* _(msg`Lorem ipsum`)
*
* After:
* import {useLingui} from '@lingui/react/macro'
* const {t: l} = useLingui()
* l`Lorem ipsum`
*
* Usage: jscodeshift -t .jscodeshift/file/lingui-v5.js <file-path>
* Example: jscodeshift -t .jscodeshift/file/lingui-v5.js modules/bottom-sheet/src/lib/Portal.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Remove msg imports, including aliased imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/core/macro')
.forEach(path => {
const newSpecifiers = path.node.specifiers.filter(specifier => {
if (j.ImportSpecifier.check(specifier)) {
const importedName = specifier.imported
? specifier.imported.name
: specifier.local.name
const localName = specifier.local
? specifier.local.name
: importedName
return !(
importedName === 'msg' &&
(localName === 'msg' || localName === 'msgLingui')
)
}
return true
})
if (newSpecifiers.length > 0) {
path.node.specifiers = newSpecifiers
} else {
j(path).remove()
}
})
// Update React imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/react')
.forEach(path => {
path.node.source = j.stringLiteral('@lingui/react/macro')
})
// Replace {_} with {t: l}
root
.find(j.VariableDeclarator, {
init: {
type: 'CallExpression',
callee: {name: 'useLingui'},
},
})
.replaceWith(nodePath => {
const {node} = nodePath
for (let i = 0; i < node.id.properties.length; i++) {
if (node.id.properties[i].key.name === '_') {
node.id.properties[i].key = j.identifier('t')
node.id.properties[i].shorthand = false
node.id.properties[i].value = j.identifier('l')
}
}
return node
})
// Replace {_: _l} with {t: l}
root
.find(j.VariableDeclarator, {
init: {callee: {name: 'useLingui'}},
id: {
properties: properties =>
properties.some(p => p.key.name === '_' && p.value.name === '_l'),
},
})
.forEach(path => {
const prop = path.node.id.properties.find(
p => p.key.name === '_' && p.value.name === '_l',
)
if (prop) {
prop.key.name = 't'
prop.value.name = 'l'
}
})
// Replace _(msg``) with l``
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msg'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _l(msgLingui``) with l``
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _(msg()) with l()
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msg'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _l(msgLingui()) with l()
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _(plural()) with plural()
root
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: '_',
},
})
.forEach(path => {
const callExpression = path.node
if (
callExpression.arguments.length === 1 &&
callExpression.arguments[0].type === 'CallExpression'
) {
const innerCall = callExpression.arguments[0]
if (
innerCall.callee.type === 'Identifier' &&
innerCall.callee.name === 'plural'
) {
j(path).replaceWith(innerCall)
}
}
})
// Update hook dependency arrays
root
.find(j.CallExpression, {
callee: callee => {
if (
callee.type === 'Identifier' ||
callee.type === 'MemberExpression'
) {
return true
}
if (
['useCallback', 'useEffect', 'useMemo'].includes(callee.name) &&
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'React' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'useCallback'
) {
return true
}
return false
},
})
.forEach(path => {
const dependencyArray = path.node.arguments[1]
if (dependencyArray && dependencyArray.type === 'ArrayExpression') {
dependencyArray.elements = dependencyArray.elements.map(element => {
if (
element.type === 'Identifier' &&
(element.name === '_' || element.name === '_l')
) {
return j.identifier('l')
}
return element
})
}
})
return root.toSource()
}
@@ -9,8 +9,8 @@
* import { useEffect } from 'react'
* useEffect(() => {}, [])
*
* Usage: jscodeshift -t .jscodeshift/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/react-import.js src/App.native.tsx
* Usage: jscodeshift -t .jscodeshift/repo/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/react-import.js src/App.native.tsx
*/
/* eslint-disable */
@@ -9,8 +9,8 @@
* import * as Toast from '#/components/Toast'
* Toast.show(message, {type: 'error'})
*
* Usage: jscodeshift -t .jscodeshift/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/toast-v2.js src/App.native.tsx
* Usage: jscodeshift -t .jscodeshift/repo/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/toast-v2.js src/App.native.tsx
*/
/* eslint-disable */
+1 -1
View File
@@ -1 +1 @@
20
24.15.0
+4
View File
@@ -5,10 +5,14 @@
!**/*.jsx
!**/*.ts
!**/*.tsx
!**/*.mjs
!**/*.cjs
!*/
!**/*.css
!**/pnpm-workspace.yaml
# More specific ignores go below.
.expo
android
+179 -95
View File
@@ -1,4 +1,4 @@
# CLAUDE.md - Bluesky Social App Development Guide
# CLAUDE.md  Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
@@ -7,36 +7,40 @@ This document provides guidance for working effectively in the Bluesky Social ap
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React 19.1
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TypeScript 6
- React Navigation 7 for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Lingui 5 for internationalization
- Custom design system called ALF (Application Layout Framework)
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
## Essential Commands
```bash
# Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
pnpm start # Start Expo dev server
pnpm web # Start web version
pnpm android # Run on Android
pnpm ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
pnpm test # Run Jest tests
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript type checking
# Internationalization
# DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
pnpm intl:extract # Extract translation strings (nightly CI job)
pnpm intl:compile # Compile translations for runtime (nightly CI job)
# Build
yarn build-web # Build web version
yarn prebuild # Generate native projects
pnpm build-web # Build web version
pnpm prebuild # Generate native projects
```
## Project Structure
@@ -162,6 +166,10 @@ its own looks more like a single component file.
### Documentation and Tests Within Features
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 `—`.
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
@@ -182,6 +190,14 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
### Basic Usage
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```tsx
import {atoms as a, useTheme} from '#/alf'
@@ -190,9 +206,7 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
</View>
)
}
@@ -200,20 +214,23 @@ function MyComponent() {
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
**Static Atoms**  Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
**Theme Atoms**  Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
**Platform Utilities**  For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
@@ -225,7 +242,8 @@ const styles = [
]
```
**Breakpoints** - Responsive design:
**Breakpoints**  Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
@@ -245,6 +263,38 @@ if (gtMobile) {
## Component Patterns
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
- Prefer functions over arrow functions for component declarations.
- Prefer prop destructuring via parameters over a const within the component.
- Prefer inline types over `Props` types or interfaces.
- Set reasonable defaults for optional props.
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>{index}</Text>
<Text>{foo}</Text>
</Fragment>
))}
</View>
</>
);
}
```
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
@@ -263,23 +313,29 @@ function MyFeature() {
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
<DialogInner />
</Dialog.Outer>
</>
)
}
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</>
)
}
```
### Menu Component
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
@@ -384,43 +441,70 @@ import * as TextField from '#/components/forms/TextField'
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one  two` over `one—two`.
```tsx
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
function MyComponent() {
const {_} = useLingui()
const {t: l} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Simple strings - use the l macro
const title = l`Settings`
const errorMessage = l({
message: 'Something went wrong',
comment: 'Generic error message for unknown/unhandled errors.',
context: 'Toast',
})
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
const greeting = l`Hello, ${name}!`
// Pluralization
const countLabel = _(plural(count, {
const countLabel = plural(count, {
one: '# item',
other: '# items',
}))
})
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
<Trans>
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
</Trans>
</Text>
)
}
```
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
```tsx
import {useLingui} from '@lingui/react/macro'
function MyComponent() {
const {i18n} = useLingui()
const createdAt = new Date()
return i18n.date(createdAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})
}
```
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile translations for runtime
pnpm intl:extract # Extract new strings to locale files
pnpm intl:compile # Compile translations for runtime
```
## State Management
@@ -473,7 +557,7 @@ export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
@@ -481,7 +565,7 @@ export function useProfileMutation() {
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: (error) => {
onError: error => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
@@ -490,7 +574,7 @@ export function useProfileMutation() {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
}
},
})
}
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
@@ -565,12 +653,7 @@ function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
}
```
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
}
// Programmatic navigation
@@ -637,8 +716,9 @@ Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
- `src/components/Dialog/index.tsx Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
@@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase:
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
@@ -720,12 +801,13 @@ const onConfirm = () => {
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
The Menu component on iOS specifically uses this pattern  see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
- `Dialog.Handle Only renders on native (drag handle for bottom sheet)
- `Dialog.Close Only renders on web (X button)
- `Menu.Divider Only renders on web
- `Menu.ContainerItem Only works on native
Always test on multiple platforms when using these components.
@@ -772,6 +855,7 @@ const handlePress = () => {
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
@@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
| Purpose | Location |
| ----------------- | -------------------------------------------- |
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
+5 -5
View File
@@ -7,7 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
#
# Node
#
ENV NODE_VERSION=20
ENV NODE_VERSION=24.15.0
ENV NVM_DIR=/usr/share/nvm
#
@@ -65,11 +65,11 @@ RUN \. "$NVM_DIR/nvm.sh" && \
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \
npm install --global yarn && \
yarn && \
yarn intl:build 2>&1 | tee i18n.log && \
npm install --global pnpm@11.1.1 && \
pnpm install --frozen-lockfile && \
pnpm intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER yarn build-web
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER pnpm build-web
# DEBUG
RUN find ./bskyweb/static && find ./web-build/static
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:20.11-alpine3.18 as build
FROM node:24.15.0-alpine3.22 AS build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
FROM node:24.15.0-alpine3.22
RUN apk add --update dumb-init
@@ -29,8 +29,6 @@ VOLUME /app/data
EXPOSE 3000
ENV LINK_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:20.11-alpine3.18 as build
FROM node:24.15.0-alpine3.22 AS build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
FROM node:24.15.0-alpine3.22
RUN apk add --update dumb-init
@@ -30,8 +30,6 @@ EXPOSE 3000 3001
ENV CARD_PORT=3000
ENV CARD_METRICS_PORT=3001
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+11 -7
View File
@@ -5,16 +5,20 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=20
ENV NODE_VERSION=24.15.0
ENV NVM_DIR=/usr/share/nvm
# Go
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ARG TARGETARCH
ENV GOARCH=${TARGETARCH:-amd64}
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# CI environment for pnpm
ENV CI=true
COPY . .
#
@@ -29,11 +33,11 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global yarn && \
yarn && \
cd bskyembed && yarn install --frozen-lockfile && cd .. && \
yarn intl:build && \
yarn build-embed
npm install --global pnpm@11.1.3 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
pnpm build-embed
# DEBUG
RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
+11 -11
View File
@@ -10,33 +10,33 @@ help: ## Print info about all commands
.PHONY: build-web
build-web: ## Compile web bundle, copy to bskyweb directory
yarn intl:build
yarn build-web
pnpm intl:build
pnpm build-web
.PHONY: build-web-embed
build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
yarn intl:build
yarn build-embed
pnpm intl:build
pnpm build-embed
.PHONY: test
test: ## Run all tests
NODE_ENV=test yarn test
NODE_ENV=test pnpm test
.PHONY: lint
lint: ## Run style checks and verify syntax
yarn run lint
pnpm run lint
#.PHONY: fmt
#fmt: ## Run syntax re-formatting
# yarn prettier
# pnpm prettier
.PHONY: deps
deps: ## Installs dependent libs using 'yarn install'
yarn install --frozen-lockfile
deps: ## Installs dependent libs using 'pnpm install'
pnpm install --frozen-lockfile
cd bskyembed && yarn install --frozen-lockfile
.PHONY: nvm-setup
nvm-setup: ## Use NVM to install and activate node+yarn
nvm-setup: ## Use NVM to install and activate node+pnpm
nvm install 20
nvm use 20
npm install --global yarn
npm install --global pnpm
+1 -17
View File
@@ -8,8 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -1072,21 +1071,6 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
describe('tenorUrlToBskyGifUrl', () => {
const inputs = [
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
]
it.each(inputs)(
'returns url with t.gifs.bsky.app as hostname for input url',
input => {
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
},
)
})
describe('klipyUrlToBskyGifUrl', () => {
const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
+3
View File
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
@@ -260,6 +262,7 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
cxxLanguageStandard: 'c++23',
extraPods: [
{
name: 'MCEmojiPicker',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32c0 3.463.765 6.745 2.134 9.688.358.769.479 1.658.267 2.525l-2.076 8.51a1.667 1.667 0 0 0 1.987 2.02l8.876-2.005a3.84 3.84 0 0 1 2.412.26A22.9 22.9 0 0 0 32 55c12.703 0 23-10.297 23-23Zm2 0c0 13.807-11.193 25-25 25-3.638 0-7.098-.778-10.219-2.177a1.84 1.84 0 0 0-1.152-.133l-8.877 2.004c-2.655.6-5.015-1.802-4.37-4.446l2.077-8.51a1.84 1.84 0 0 0-.139-1.207A24.9 24.9 0 0 1 7 32C7 18.193 18.193 7 32 7s25 11.193 25 25Z"/><path fill="#111822" d="M17.667 32a2.333 2.333 0 1 0 4.667 0 2.333 2.333 0 0 0-4.667 0Zm24 0a2.334 2.334 0 1 0 4.667 0 2.334 2.334 0 0 0-4.667 0Z"/><path fill="#000" d="M35.137 37.215a1 1 0 0 1 1.414 1.414 6.143 6.143 0 0 1-8.687 0 1 1 0 0 1 1.415-1.414 4.14 4.14 0 0 0 5.858 0Z"/></svg>

After

Width:  |  Height:  |  Size: 847 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>

After

Width:  |  Height:  |  Size: 807 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19 14h-2.417a5 5 0 0 1-9.166 0H5v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4Zm0-8a1 1 0 0 0-.898-.995L18 5H6a1 1 0 0 0-1 1v6h3.126a1 1 0 0 1 .969.751 3.001 3.001 0 0 0 5.81 0l.056-.16a1 1 0 0 1 .913-.591H19V6Zm2 12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12l.154.004A3 3 0 0 1 21 6v12Z"/></svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M57.666 33.416q-.001-.21-.022-.419L57.6 33H44a4.33 4.33 0 0 0-3.466 1.734l-.8 1.066a6.34 6.34 0 0 1-5.068 2.533h-5.333a6.33 6.33 0 0 1-5.066-2.533l-.8-1.066A4.33 4.33 0 0 0 20 33H6.4l-.045-.003a4 4 0 0 0-.022.42v11.916a4.333 4.333 0 0 0 4.333 4.333h42.667a4.333 4.333 0 0 0 4.333-4.333V33.416Zm-38.57-19.083a4.33 4.33 0 0 0-3.764 2.184L7.057 31H20c1.994 0 3.87.939 5.067 2.533l.8 1.067a4.33 4.33 0 0 0 3.466 1.733h5.333a4.33 4.33 0 0 0 3.467-1.733l.8-1.067A6.34 6.34 0 0 1 44 31h12.944l-8.277-14.483a4.33 4.33 0 0 0-3.762-2.184h-25.81Zm40.57 31a6.333 6.333 0 0 1-6.333 6.333H10.666a6.333 6.333 0 0 1-6.333-6.333V33.416c0-1.102.288-2.185.835-3.142l8.428-14.75a6.33 6.33 0 0 1 5.5-3.19h25.809a6.33 6.33 0 0 1 5.499 3.19l8.427 14.75h.001a6.33 6.33 0 0 1 .834 3.142v11.917Z"/></svg>

After

Width:  |  Height:  |  Size: 875 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M14.5 8a1 1 0 0 1 .912.59l4.5 10a1 1 0 0 1-1.824.82L16.554 16h-4.108l-1.534 3.41a1 1 0 0 1-1.824-.82l4.5-10A1 1 0 0 1 14.5 8m-1.153 6h2.306L14.5 11.437zM5.053.106a1 1 0 0 1 1.342.447L7.118 2H11a1 1 0 0 1 0 2H9.92c-.28 1.765-1.27 3.24-2.393 4.398a17 17 0 0 0 2.605 1.562q.129.062.197.092l.005.002.049.022.01.005a1 1 0 0 1-.787 1.838h-.002l-.027-.013-.068-.03-.242-.113A19 19 0 0 1 6 9.75a19 19 0 0 1-3.509 2.125l-.068.03-.027.013h-.002a1 1 0 0 1-.788-1.838l.011-.005.049-.022.005-.002q.069-.03.197-.092a17 17 0 0 0 2.604-1.562C3.348 7.239 2.36 5.764 2.08 4H1a1 1 0 0 1 0-2h3.882l-.277-.553A1 1 0 0 1 5.053.106M4.118 4c.27 1.151.98 2.193 1.882 3.1.901-.907 1.612-1.95 1.882-3.1z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 819 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>

After

Width:  |  Height:  |  Size: 376 B

+3
View File
@@ -47,6 +47,9 @@ module.exports = function (api) {
production: {
plugins: ['transform-remove-console'],
},
test: {
plugins: ['@babel/plugin-transform-class-static-block'],
},
},
}
}
+3 -3
View File
@@ -4,13 +4,13 @@ Install dependencies:
```bash
cd bskyembed
yarn
pnpm
```
Run the dev server:
```bash
yarn dev
pnpm dev
```
You can see the embed homepage at http://localhost:5173
@@ -20,7 +20,7 @@ You can see the embed homepage at http://localhost:5173
In another terminal window, run the snippet dev script:
```bash
yarn dev-snippet
pnpm dev-snippet
```
You can then see the testbed page at http://localhost:5173/test
+8 -2
View File
@@ -1,10 +1,12 @@
// @ts-check
import js from '@eslint/js'
import {defineConfig} from 'eslint/config'
import tseslint from 'typescript-eslint'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import {importX} from 'eslint-plugin-import-x'
import globals from 'globals'
export default tseslint.config(
export default defineConfig(
// Global ignores
{
ignores: ['dist/**', 'node_modules/**'],
@@ -14,7 +16,11 @@ export default tseslint.config(
js.configs.recommended,
// TypeScript rules with type checking
...tseslint.configs.recommendedTypeChecked,
tseslint.configs.recommendedTypeChecked,
// import-x
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
// Main configuration
{
+12 -8
View File
@@ -7,29 +7,33 @@
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"analyze": "ANALYZE=1 vite build",
"lint": "eslint --cache src",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"format": "prettier -w src"
},
"dependencies": {
"@atproto/api": "^0.15.25",
"preact": "^10.4.8"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^7.0.0",
"@eslint/js": "^9.18.0",
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^8.0.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.18.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-simple-import-sort": "^13.0.0",
"globals": "^15.14.0",
"postcss": "^8.4.38",
"typescript-eslint": "^8.20.0",
"prettier": "^3.8.3",
"serve": "^14.2.5",
"tailwindcss": "^3.4.3",
"terser": "^5.43.1",
"typescript": "^5.8.3",
"vite": "^7.0.4",
"vite-tsconfig-paths": "^5.1.4"
"typescript": "^6.0.3",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.12",
"vite-bundle-analyzer": "^1.3.8"
},
"resolutions": {
"@types/estree": "1.0.6"
+4655
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
allowBuilds:
'@sentry/cli': true
'core-js': true
'esbuild': true
'unrs-resolver': true
minimumReleaseAgeExclude:
- '@atproto/*'
- '@bsky.app/*'
+1
View File
@@ -416,6 +416,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
controls
playsinline
preload="metadata"
// @ts-expect-error https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#loading
loading="lazy"
aria-label={content.alt || undefined}
onClickCapture={evt => evt.stopPropagation()}
+3 -5
View File
@@ -1,11 +1,10 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"target": "ES2015",
"lib": ["DOM", "ESNext"],
"types": ["vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
@@ -17,7 +16,6 @@
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", "eslint.config.mjs"]
}
+3 -2
View File
@@ -1,8 +1,9 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"target": "ES2015",
"lib": ["DOM", "ESNext"],
"strict": true,
"rootDir": "snippet",
"outDir": "dist"
},
"include": ["snippet"]
+8 -3
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs'
import {resolve} from 'node:path'
import preact from '@preact/preset-vite'
import {preact} from '@preact/preset-vite'
import legacy from '@vitejs/plugin-legacy'
import type {Plugin, UserConfig} from 'vite'
import paths from 'vite-tsconfig-paths'
import {analyzer} from 'vite-bundle-analyzer'
/**
* World's hackiest router, for dev only. Serves `/post.html` to requests that start with `/embed/`
@@ -38,11 +38,13 @@ function devOnlyRouter(): Plugin {
const config: UserConfig = {
plugins: [
preact(),
paths(),
legacy({
targets: ['defaults', 'not IE 11'],
}),
devOnlyRouter(),
analyzer({
enabled: process.env.ANALYZE === '1',
}),
],
build: {
assetsDir: 'static',
@@ -53,6 +55,9 @@ const config: UserConfig = {
},
},
},
resolve: {
tsconfigPaths: true,
},
}
export default config
-3516
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"main": "index.ts",
"scripts": {
"test": "npm run test:unit && npm run test:e2e",
"test": "yarn run test:unit && yarn run test:e2e",
"test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test:unit": "node --loader ts-node/esm --test ./src/*.test.ts",
"build": "tsc"
+7 -6
View File
@@ -4,10 +4,10 @@
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"start": "tsx ./src/bin.ts",
"dev": "node --watch-path ./src --import tsx ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
"install-fonts": "tsx scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
@@ -24,8 +24,9 @@
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"@types/node": "^24.12.2",
"@types/react": "^18.3.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import cluster, {Worker} from 'node:cluster'
import cluster, {type Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
+121
View File
@@ -0,0 +1,121 @@
import {Img} from './Img.js'
const SCALE = 3
const BASE_SIZE = 120
const PADDING = 4
const CONTENT_SCALE = (BASE_SIZE - PADDING * 2) / BASE_SIZE
export const AVATAR_BUBBLES_SIZE = BASE_SIZE * SCALE
type BubbleConfig = {size: number; x: number; y: number}
const LAYOUTS: Record<1 | 2 | 3 | 4, BubbleConfig[]> = {
1: [
{size: 76, x: 0, y: 0},
{size: 76, x: 44, y: 44},
],
2: [
{size: 76, x: 0, y: 0},
{size: 76, x: 44, y: 44},
],
3: [
{size: 68, x: 0.5, y: 0},
{size: 56, x: 40.5, y: 64},
{size: 46, x: 73.5, y: 20},
],
4: [
{size: 68, x: 1, y: 1},
{size: 56, x: 63, y: 52},
{size: 42, x: 17, y: 77},
{size: 32, x: 75, y: 12},
],
}
const PERSON_PATH =
'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z'
export function AvatarBubbles(props: {images: (Buffer | null)[]}) {
const count = Math.min(props.images.length, 4) as 1 | 2 | 3 | 4
const layout = LAYOUTS[count] ?? LAYOUTS[2]
const bubbles = layout.map((bubble, i) => ({
...bubble,
image: props.images[i] ?? null,
}))
// For the 2-avi layout, render in reverse so the first (top-left) paints last (on top)
if (count <= 2) {
bubbles.reverse()
}
return (
<div
style={{
display: 'flex',
width: AVATAR_BUBBLES_SIZE,
height: AVATAR_BUBBLES_SIZE,
position: 'relative',
}}>
{bubbles.map((bubble, i) => (
<AvatarBubble
key={i}
image={bubble.image}
size={bubble.size * CONTENT_SCALE * SCALE}
x={bubble.x * CONTENT_SCALE * SCALE + PADDING * SCALE}
y={bubble.y * CONTENT_SCALE * SCALE + PADDING * SCALE}
/>
))}
</div>
)
}
function AvatarBubble(props: {
image: Buffer | null
size: number
x: number
y: number
}) {
return (
<div
style={{
display: 'flex',
position: 'absolute',
left: props.x,
top: props.y,
width: props.size,
height: props.size,
borderRadius: '50%',
overflow: 'hidden',
}}>
{props.image ? (
<Img width="100%" height="100%" src={props.image} />
) : (
<Placeholder size={props.size} />
)}
</div>
)
}
function Placeholder(props: {size: number}) {
const iconSize = props.size * 0.5
const offset = (props.size - iconSize) / 2
return (
<div
style={{
display: 'flex',
width: props.size,
height: props.size,
backgroundColor: '#b0b0b0',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg
width={iconSize}
height={iconSize}
viewBox="0 0 24 24"
style={{position: 'absolute', left: offset, top: offset}}>
<path d={PERSON_PATH} fill="white" />
</svg>
</div>
)
}
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
// @NOTE satori does not currently support webp, see vercel/satori#273
function detectMime(buf: Buffer): string {
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
+1 -2
View File
@@ -1,6 +1,5 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
+8 -5
View File
@@ -4,7 +4,7 @@ import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api'
import {Config} from './config.js'
import {type Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
@@ -20,10 +20,13 @@ export class AppContext {
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
private opts: AppContextOptions
constructor(opts: AppContextOptions) {
this.opts = opts
this.cfg = opts.cfg
this.appviewAgent = opts.appviewAgent
this.fonts = opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
+7 -4
View File
@@ -19,10 +19,13 @@ export class CardService {
private terminator?: HttpTerminator
private metricsTerminator?: HttpTerminator
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
public app: express.Application
public ctx: AppContext
constructor(app: express.Application, ctx: AppContext) {
this.app = app
this.ctx = ctx
}
static async create(cfg: Config): Promise<CardService> {
let app = express()
+69
View File
@@ -0,0 +1,69 @@
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
import {
AVATAR_BUBBLES_SIZE,
AvatarBubbles,
} from '../components/AvatarBubbles.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/avatar-bubbles',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const didParam = req.query.did
const dids = (Array.isArray(didParam) ? didParam : [didParam]).filter(
(d): d is string => typeof d === 'string' && d.length > 0,
)
if (dids.length < 1 || dids.length > 4) {
return res.status(400).end('did param must contain 1-4 DIDs')
}
let profiles
try {
const result = await ctx.appviewAgent.api.app.bsky.actor.getProfiles({
actors: dids,
})
profiles = result.data.profiles
} catch (err) {
httpLogger.warn({err, dids}, 'could not fetch profiles')
return res.status(502).end('could not fetch profiles')
}
const images = await Promise.all(
dids.map(async did => {
const profile = profiles.find(p => p.did === did)
if (!profile?.avatar) return null
if (profile.labels?.some(l => hideAvatarLabels.has(l.val)))
return null
try {
return await getImage(profile.avatar)
} catch (err) {
httpLogger.warn({err, did}, 'could not fetch avatar image')
return null
}
}),
)
const svg = await satori(<AvatarBubbles images={images} />, {
fonts: ctx.fonts,
height: AVATAR_BUBBLES_SIZE,
width: AVATAR_BUBBLES_SIZE,
})
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', dids.join(','))
return res.end(output.asPng())
}),
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {Express} from 'express'
import {type Express} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
+4 -2
View File
@@ -1,6 +1,7 @@
import {Express} from 'express'
import {type Express} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {default as avatarBubbles} from './avatar-bubbles.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
@@ -9,5 +10,6 @@ export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=...
return app
}
+16 -38
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert'
import React from 'react'
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
@@ -14,7 +13,12 @@ import {
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js'
import {
getImage,
handler,
hideAvatarLabels,
originVerifyMiddleware,
} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
@@ -38,11 +42,11 @@ export default function (ctx: AppContext, app: Express) {
}
const imageEntries = await Promise.all(
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
.concat((starterPack.listItemsSample ?? []).map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.filter(p => !p.labels?.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
@@ -58,7 +62,12 @@ export default function (ctx: AppContext, app: Express) {
}),
)
const images = new Map(
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
imageEntries
.filter(
(entry): entry is readonly [string, Buffer<ArrayBuffer>] =>
entry[1] !== null,
)
.slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
@@ -68,8 +77,9 @@ export default function (ctx: AppContext, app: Express) {
width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
return (await loadEmojiAsSvg(text)) ?? ''
}
return ''
},
},
)
@@ -81,35 +91,3 @@ export default function (ctx: AppContext, app: Express) {
}),
)
}
async function getImage(url: string) {
const response = await fetch(ensureJpeg(url))
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
function ensureJpeg(url: string) {
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'gore',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
+39 -2
View File
@@ -1,6 +1,11 @@
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {
type ErrorRequestHandler,
type Request,
type RequestHandler,
type Response,
} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
@@ -34,3 +39,35 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
}
return res.status(500).end('server error')
}
export async function getImage(url: string) {
const response = await fetch(ensureJpeg(url))
const arrayBuf = await response.arrayBuffer()
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
function ensureJpeg(url: string) {
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
}
export const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'gore',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
+4 -1
View File
@@ -3,9 +3,12 @@
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"erasableSyntaxOnly": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
"rootDir": "./src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
+218 -117
View File
@@ -90,12 +90,135 @@
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f"
integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@esbuild/aix-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
"@esbuild/android-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
"@esbuild/android-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
"@esbuild/android-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
"@esbuild/darwin-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322"
integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
"@esbuild/darwin-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
"@esbuild/freebsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
"@esbuild/freebsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
"@esbuild/linux-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
"@esbuild/linux-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
"@esbuild/linux-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
"@esbuild/linux-loong64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
"@esbuild/linux-mips64el@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
"@esbuild/linux-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
"@esbuild/linux-riscv64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
"@esbuild/linux-s390x@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
"@esbuild/linux-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
"@esbuild/netbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
"@esbuild/netbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
"@esbuild/openbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
"@esbuild/openbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
"@esbuild/openharmony-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
"@esbuild/sunos-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
"@esbuild/win32-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
"@esbuild/win32-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
"@esbuild/win32-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
"@ipld/dag-cbor@^7.0.3":
version "7.0.3"
@@ -105,24 +228,6 @@
cborg "^1.6.0"
multiformats "^9.5.4"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@opentelemetry/api@^1.4.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
@@ -214,26 +319,6 @@
fflate "^0.7.3"
string.prototype.codepointat "^0.2.1"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==
"@tsconfig/node12@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
"@tsconfig/node14@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node16@^1.0.2":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@types/body-parser@*":
version "1.19.6"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
@@ -286,12 +371,17 @@
dependencies:
undici-types "~7.12.0"
"@types/node@^20.14.3":
version "20.14.3"
resolved "https://registry.npmjs.org/@types/node/-/node-20.14.3.tgz"
integrity sha512-Nuzqa6WAxeGnve6SXqiPAM9rA++VQs+iLZ1DDd56y0gdvygSZlQvZuvdFPR3yLqkVxPu4WrO02iDEyH1g+wazw==
"@types/node@^24.12.2":
version "24.12.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.12.2.tgz#353cb161dbf1785ea25e8829ba7ec574c5c629ac"
integrity sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==
dependencies:
undici-types "~5.26.4"
undici-types "~7.16.0"
"@types/prop-types@*":
version "15.7.15"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
"@types/qs@*":
version "6.14.0"
@@ -303,6 +393,14 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react@^18.3.1":
version "18.3.28"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781"
integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==
dependencies:
"@types/prop-types" "*"
csstype "^3.2.2"
"@types/send@*":
version "0.17.5"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
@@ -335,23 +433,6 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-walk@^8.1.1:
version "8.3.3"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e"
integrity sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==
dependencies:
acorn "^8.11.0"
acorn@^8.11.0, acorn@^8.4.1:
version "8.12.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
@@ -521,11 +602,6 @@ cookie@0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
css-background-parser@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz"
@@ -550,6 +626,11 @@ css-to-react-native@^3.0.0:
css-color-keywords "^1.0.0"
postcss-value-parser "^4.0.2"
csstype@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
@@ -586,11 +667,6 @@ detect-libc@^2.0.1:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -644,6 +720,38 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
dependencies:
es-errors "^1.3.0"
esbuild@~0.27.0:
version "0.27.7"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f"
integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==
optionalDependencies:
"@esbuild/aix-ppc64" "0.27.7"
"@esbuild/android-arm" "0.27.7"
"@esbuild/android-arm64" "0.27.7"
"@esbuild/android-x64" "0.27.7"
"@esbuild/darwin-arm64" "0.27.7"
"@esbuild/darwin-x64" "0.27.7"
"@esbuild/freebsd-arm64" "0.27.7"
"@esbuild/freebsd-x64" "0.27.7"
"@esbuild/linux-arm" "0.27.7"
"@esbuild/linux-arm64" "0.27.7"
"@esbuild/linux-ia32" "0.27.7"
"@esbuild/linux-loong64" "0.27.7"
"@esbuild/linux-mips64el" "0.27.7"
"@esbuild/linux-ppc64" "0.27.7"
"@esbuild/linux-riscv64" "0.27.7"
"@esbuild/linux-s390x" "0.27.7"
"@esbuild/linux-x64" "0.27.7"
"@esbuild/netbsd-arm64" "0.27.7"
"@esbuild/netbsd-x64" "0.27.7"
"@esbuild/openbsd-arm64" "0.27.7"
"@esbuild/openbsd-x64" "0.27.7"
"@esbuild/openharmony-arm64" "0.27.7"
"@esbuild/sunos-x64" "0.27.7"
"@esbuild/win32-arm64" "0.27.7"
"@esbuild/win32-ia32" "0.27.7"
"@esbuild/win32-x64" "0.27.7"
escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
@@ -810,6 +918,11 @@ fs-extra@^8.0.1:
jsonfile "^4.0.0"
universalify "^0.1.0"
fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -850,6 +963,13 @@ get-proto@^1.0.1:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
get-tsconfig@^4.7.5:
version "4.14.0"
resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8"
integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==
dependencies:
resolve-pkg-maps "^1.0.0"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz"
@@ -990,11 +1110,6 @@ loose-envify@^1.1.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -1273,6 +1388,11 @@ real-require@^0.2.0:
resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz"
integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
resolve-pkg-maps@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
roarr@^7.0.4:
version "7.21.1"
resolved "https://registry.npmjs.org/roarr/-/roarr-7.21.1.tgz"
@@ -1515,24 +1635,15 @@ toidentifier@1.0.1:
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
ts-node@^10.9.2:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
tsx@^4.21.0:
version "4.21.0"
resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1"
integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
esbuild "~0.27.0"
get-tsconfig "^4.7.5"
optionalDependencies:
fsevents "~2.3.3"
twemoji-parser@14.0.0:
version "14.0.0"
@@ -1562,10 +1673,10 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@^5.4.5:
version "5.4.5"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz"
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
typescript@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
uint8arrays@3.0.0:
version "3.0.0"
@@ -1574,16 +1685,16 @@ uint8arrays@3.0.0:
dependencies:
multiformats "^9.4.2"
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~7.12.0:
version "7.12.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.12.0.tgz#15c5c7475c2a3ba30659529f5cdb4674b622fafb"
integrity sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
unicode-trie@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz"
@@ -1612,21 +1723,11 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
vary@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
yoga-wasm-web@^0.3.3:
version "0.3.3"
resolved "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz"
+5 -5
View File
@@ -10,13 +10,13 @@ like:
# install nodejs
nvm install
nvm use
npm install --global yarn
npm install --global pnpm
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
pnpm install --frozen-lockfile
# run yarn web dev server, if you wanted
yarn web
# run pnpm web dev server, if you wanted
pnpm web
```
Then build and copy over the big 'ol `bundle.web.js` file:
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
```bash
# in the top level of this repo
yarn build-web
pnpm build-web
```
### Golang Daemon
+9
View File
@@ -30,6 +30,7 @@ import (
"github.com/flosch/pongo2/v6"
"github.com/klauspost/compress/gzhttp"
"github.com/klauspost/compress/gzip"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/urfave/cli/v2"
@@ -201,6 +202,14 @@ func serve(cctx *cli.Context) error {
RedirectCode: http.StatusFound,
}))
echoprom := echoprometheus.NewMiddlewareWithConfig(
echoprometheus.MiddlewareConfig{
DoNotUseRequestPathFor404: true,
},
)
e.Use(echoprom)
// CORS middleware
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: corsOrigins,
+1 -1
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin">
<!--
Preconnect to essential domains
+2 -2
View File
@@ -1,6 +1,6 @@
{
"scripts": {
"setup": "yarn install",
"run": "yarn web --port $CONDUCTOR_PORT"
"setup": "pnpm install --frozen-lockfile",
"run": "pnpm web --port $CONDUCTOR_PORT"
}
}
+13 -13
View File
@@ -2398,13 +2398,13 @@ await-lock@^2.2.2:
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
axios@^1.3.4:
version "1.13.6"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.6.tgz#c3f92da917dc209a15dd29936d20d5089b6b6c98"
integrity sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==
version "1.16.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.0.tgz#f8e5dd931cef2a5f8c32216d5784eda2f8750eb7"
integrity sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==
dependencies:
follow-redirects "^1.15.11"
follow-redirects "^1.16.0"
form-data "^4.0.5"
proxy-from-env "^1.1.0"
proxy-from-env "^2.1.0"
balanced-match@^1.0.0:
version "1.0.2"
@@ -3002,10 +3002,10 @@ finalhandler@~1.3.1:
statuses "~2.0.2"
unpipe "~1.0.0"
follow-redirects@^1.15.11:
version "1.15.11"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
foreground-child@^3.1.0:
version "3.3.1"
@@ -3756,10 +3756,10 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
pump@^3.0.0:
version "3.0.4"
+19 -19
View File
@@ -2,8 +2,8 @@
## Running Web App
- `yarn`
- `yarn web`
- `pnpm install`
- `pnpm web`
You're all set!
@@ -32,11 +32,11 @@ This is NOT required when developing for web.
- `bundler install` (this will install Cocoapods)
- After initial setup:
- Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required)
- `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
- `pnpm prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change
### Running the Native App
- iOS: `yarn ios`
- iOS: `pnpm ios`
- Xcode must be installed for this to run.
- A simulator must be preconfigured in Xcode settings.
- if no iOS versions are available, install the iOS runtime at `Xcode > Settings > Platforms`.
@@ -48,24 +48,24 @@ This is NOT required when developing for web.
- In addition, ensure Xcode Command Line Tools are installed using `xcode-select --install`.
- Expo will require you to configure Xcode Signing. Follow the linked instructions. Error messages in Xcode related to the signing process can be safely ignored when installing on the iOS Simulator; Expo merely requires the profile to exist in order to install the app on the Simulator.
- Make sure you do have a certificate: open Xcode > Settings > Accounts > (sign-in) > Manage Certificates > + > Apple Development > Done.
- If you still encounter issues, try `rm -rf ios` before trying to build again (`yarn ios`)
- Android: `yarn android`
- If you still encounter issues, try `rm -rf ios` before trying to build again (`pnpm ios`)
- Android: `pnpm android`
- Install "Android Studio"
- Make sure you have the Android SDK installed (Android Studio > Tools > Android SDK).
- In "SDK Platforms": "Android x" (where x is Android's current version).
- In "SDK Tools": "Android SDK Build-Tools" and "Android Emulator" are required.
- Add `export ANDROID_HOME=/Users/<your_username>/Library/Android/sdk` to your `.zshrc` or `.bashrc` (and restart your terminal).
- Setup an emulator (Android Studio > Tools > Device Manager).
- Web: `yarn web` (see the top of this file).
- Web: `pnpm web` (see the top of this file).
After you do `yarn ios` and `yarn android` once, you can later just run `yarn web` and then press either `i` or `a` to open iOS and Android emulators respectively which is much faster. However, if you make native changes, you'll have to do `yarn prebuild -p ios` and `yarn prebuild -p android` and then `yarn ios` and `yarn android` again before you can continue with the same workflow.
After you do `pnpm ios` and `pnpm android` once, you can later just run `pnpm web` and then press either `i` or `a` to open iOS and Android emulators respectively which is much faster. However, if you make native changes, you'll have to do `pnpm prebuild -p ios` and `pnpm prebuild -p android` and then `pnpm ios` and `pnpm android` again before you can continue with the same workflow.
### Tips
- Copy the `.env.example` to `.env` and fill in any necessary tokens. (The Sentry token is NOT required; see instructions below if you want to enable Sentry.)
- To run on the device, add `--device` to the command (e.g. `yarn android --device`). To build in production mode (slower build, faster app), also add `--variant release` on Android or `--configuration Release` on iOS.
- To run on the device, add `--device` to the command (e.g. `pnpm android --device`). To build in production mode (slower build, faster app), also add `--variant release` on Android or `--configuration Release` on iOS.
- If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` and `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
- `npx react-native info` Checks what has been installed.
- `pnpm exec react-native info` Checks what has been installed.
- If the Android simulator frequently hangs or is very sluggish, [bump its memory limit](https://stackoverflow.com/a/40068396)
- The Android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
- For instance, the locally-hosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
@@ -74,10 +74,10 @@ After you do `yarn ios` and `yarn android` once, you can later just run `yarn we
### Running E2E Tests
- Start in various console tabs:
- `yarn e2e:mock-server`
- `yarn e2e:start`
- Run once: `yarn e2e:build`
- Each test run: `yarn e2e:run`
- `pnpm e2e:mock-server`
- `pnpm e2e:start`
- Run once: `pnpm e2e:build`
- Each test run: `pnpm e2e:run`
### Adding Sentry
@@ -85,7 +85,7 @@ Adding Sentry is NOT required. You can keep `SENTRY_AUTH_TOKEN=` in `.env` which
However, if you're a part of the Bluesky team and want to enable Sentry, fill in `SENTRY_AUTH_TOKEN` in your `.env`. It can be created on the Sentry dashboard using [these instructions](https://docs.expo.dev/guides/using-sentry/#sign-up-for-a-sentry-account-and-create-a-project).
If you change `SENTRY_AUTH_TOKEN`, you need to do `yarn prebuild` before running `yarn ios` or `yarn android` again.
If you change `SENTRY_AUTH_TOKEN`, you need to do `pnpm prebuild` before running `pnpm ios` or `pnpm android` again.
### Adding bitdrift
@@ -95,7 +95,7 @@ However, if you're a part of the Bluesky team and want to enable bitdrift, fill
### Adding and Updating Locales
- `yarn intl:build` -> you will also need to run this anytime `./src/locale/{locale}/messages.po` change
- `pnpm intl:build` -> you will also need to run this anytime `./src/locale/{locale}/messages.po` change
## Running the Backend Locally
@@ -112,7 +112,7 @@ This is NOT required for app development but if you also want to develop the Blu
- Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 pnpm start`
Then, when logging in or creating an account, point it to the localhost port of the devserver.
@@ -123,7 +123,7 @@ The Go server in this repository is only used for serving the web app in product
### Prerequisites
- [Go](https://go.dev/)
- [Yarn](https://yarnpkg.com/)
- [pnpm](https://pnpm.io/)
### Steps
@@ -131,7 +131,7 @@ To run the build with Go, use staging credentials, your own, or any other accoun
```
cd social-app
yarn && yarn build-web
pnpm install && pnpm build-web
cd bskyweb/
go mod tidy
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
+8 -8
View File
@@ -42,20 +42,20 @@ Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/). You will need
### English source-file sync with Crowdin
Every night, a GitHub action will run `yarn intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations.
Every night, a GitHub action will run `pnpm intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations.
### Release process
1. Pull main and create a branch.
1. Run `yarn intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
1. Run `pnpm intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
1. Create a PR, ensure the translations all look correct, and merge.
1. If needed:
1. Merge all approved translation PRs (contributions from outside crowdin).
1. Run `yarn intl:push` to sync Crowdin with the state of the repo.
1. Run `pnpm intl:push` to sync Crowdin with the state of the repo.
### Testing the translations in Crowdin
You can run `yarn intl:pull` to pull the currently-approved translations from Crowdin.
You can run `pnpm intl:pull` to pull the currently-approved translations from Crowdin.
## Developers
@@ -118,15 +118,15 @@ function sayHello() {
}
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
We can then run `pnpm intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
We can then run `pnpm intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
The configuration for translations is defined in `lingui.config.js`
So the workflow is as follows:
1. Wrap messages in Trans macro
2. Run `yarn intl:extract` command to generate message catalogs
2. Run `pnpm intl:extract` command to generate message catalogs
3. Translate message catalogs (send them to translators usually)
4. Run `yarn intl:compile` to create runtime catalogs
4. Run `pnpm intl:compile` to create runtime catalogs
5. Load runtime catalog
6. Enjoy translated app!
+6 -6
View File
@@ -25,13 +25,13 @@ adb reverse tcp:3000 tcp:3000
### Running Maestro tests
- In one tab, run `yarn e2e:mock-server`
- In a second tab, run `yarn e2e:build`
- In a third tab, run `yarn e2e:run`
- In one tab, run `pnpm e2e:mock-server`
- In a second tab, run `pnpm e2e:build`
- In a third tab, run `pnpm e2e:run`
## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
3. The simplest way to get started is by running `pnpm perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
4. The `pnpm perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `pnpm perf:results`
5. You can also run your own tests by running `pnpm perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `pnpm perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"cli": {
"version": ">= 3.8.1",
"version": ">=18",
"promptToConfigurePushNotifications": false,
"appVersionSource": "remote"
},
"build": {
"base": {
"node": "20.19.4"
"node": "24.15.0"
},
"development": {
"extends": "base",
+696
View File
@@ -0,0 +1,696 @@
{
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metadata.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/DraggableList/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/FeedCard.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/FocusScope/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/InternationalPhoneCodeSelect.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Lists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Menu/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Portal.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/PostControls/BookmarkButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/ProfileHoverCard/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/Select/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/StarterPack/ProfileStarterPacks.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/DeviceLocationRequestDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/EmailDialog/components/ResendEmailText.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/LanguageSelectDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/MutedWords.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/PostInteractionSettingsDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"react-hooks/preserve-manual-memoization": {
"count": 1
}
},
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/SegmentedControl.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/ToggleButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/hooks/useFollowMethods.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/AutoSizedImage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/images/Gallery/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/images/Gallery/useKeyboardHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/Gallery/usePointerHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/ImageLayoutGrid.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/ImageLayoutGridItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/moderation/ReportDialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/features/liveNow/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/geolocation/service.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/ScrollContext.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/lib/api/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/async/until.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/broadcast/stub.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/functions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/lib/getUserDisplayName.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useAccountSwitcher.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useNonReactiveCallback.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useRequireEmailVerification.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useToggleMutationQueue.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useWebScrollRestoration.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/international-telephone-codes.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/manip.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ios.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/merge-refs.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/react-query.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/router.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/strings/errors.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/locale/i18n.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/platform/polyfills.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Bookmarks/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Deactivated.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ChooseAccountForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ForgotPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/LoginForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/SetNewPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Moderation/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/ModerationInteractionSettings/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepFinished/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepInterests/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/PostThread/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"react-hooks/preserve-manual-memoization": {
"count": 1
}
},
"src/screens/Profile/Header/EditProfileDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/screens/Profile/Header/ProfileHeaderLabeler.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Profile/Header/Shell.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/Sections/Feed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/components/GermButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/FindContactsSettings.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/ChangePasswordDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Settings/components/DeactivateAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/DeleteAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Signup/StepInfo/Policies.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/SignupQueued.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/cache/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/feed-feedback.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/messages/events/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/persisted/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/index.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/notifications/feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/nuxs/definitions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/pinned-post.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/post-feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/queries/postgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/threadgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/shell/progress-guide.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/storage/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 8
}
},
"src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"react-hooks/preserve-manual-memoization": {
"count": 3
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/photos/OpenCameraBtn.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/feeds/ProfileFeedgens.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lists/ListMembers.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lists/MyLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/lists/ProfileLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/notifications/NotificationFeedItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/Pager.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/view/com/pager/Pager.web.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
}
},
"src/view/com/pager/PagerWithHeader.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/PagerWithHeader.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/pager/TabBar.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/FeedShutdownMsg.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/posts/PostFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EmptyState.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/ErrorBoundary.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EventStopper.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/Link.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/MainScrollProvider.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/ViewSelector.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/util/Views.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/Debug.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/DebugMod.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/shell/createNativeStackNavigatorWithAuth.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
}
}
+57 -31
View File
@@ -1,7 +1,7 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import { defineConfig } from 'eslint/config';
import {defineConfig} from 'eslint/config'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
// @ts-expect-error no types
@@ -32,6 +32,7 @@ export default defineConfig(
'*.html',
'bskyweb/**',
'bskyembed/**',
'bskyogcard/**',
'src/locale/locales/_build/**',
'src/locale/locales/**/*.js',
'*.e2e.ts',
@@ -183,24 +184,30 @@ export default defineConfig(
* Import linting
*/
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
'import-x/no-unresolved': ['error', {
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
'import-x/no-extraneous-dependencies': ['error', {
'whitelist': [
// test files only
'@jest/globals',
// we only use a really simple util from this, and we know it will be present
'expo-modules-core',
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
// rely on the transient version
'@atproto/common-web',
]
}],
'import-x/no-unresolved': [
'error',
{
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
},
],
'import-x/no-extraneous-dependencies': [
'error',
{
whitelist: [
// test files only
'@jest/globals',
// we only use a really simple util from this, and we know it will be present
'expo-modules-core',
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
// rely on the transient version
'@atproto/common-web',
],
},
],
'import-x/no-nodejs-modules': 'error',
/**
@@ -221,15 +228,18 @@ export default defineConfig(
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-expressions': ['error', {
allowTernary: true,
}],
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowTernary: true,
},
],
/**
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
@@ -250,13 +260,19 @@ export default defineConfig(
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
"no-restricted-imports": ["error", {
"paths": [{
"name": "react",
"importNames": ["React", "default"],
"message": "React is already in the global type namespace. Use named imports for runtime modules."
}]
}],
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'react',
importNames: ['React', 'default'],
message:
'React is already in the global type namespace. Use named imports for runtime modules.',
},
],
},
],
/**
* Turn off rules that we haven't enforced thus far
@@ -279,6 +295,16 @@ export default defineConfig(
},
},
/**
* bskyogcard - server-side, Node.js imports are fine
*/
{
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}'],
rules: {
'import-x/no-nodejs-modules': 'off',
},
},
/**
* Test files configuration
*/
@@ -287,7 +313,7 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.jest,
}
},
},
},
)
+7 -3
View File
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
usePermissions: jest.fn(() => [true]),
}))
jest.mock('lande', () => ({
__esModule: true, // this property makes it work
default: jest.fn().mockReturnValue([['eng']]),
jest.mock('@bsky.app/expo-guess-language', () => ({
guessLanguageSync: jest
.fn()
.mockReturnValue([{language: 'en', confidence: 1}]),
guessLanguageAsync: jest
.fn()
.mockResolvedValue([{language: 'en', confidence: 1}]),
}))
jest.mock('sentry-expo', () => ({
+1 -28
View File
@@ -2,6 +2,7 @@
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
const cfg = getSentryExpoConfig(__dirname)
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
: cfg.resolver.sourceExts
@@ -16,25 +17,7 @@ if (process.env.BSKY_PROFILE) {
cfg.resolver.assetExts = [...cfg.resolver.assetExts, 'woff2']
// Enabled by default in RN 0.79+, but this breaks Lingui + others
cfg.resolver.unstable_enablePackageExports = false
cfg.resolver.resolveRequest = (context, moduleName, platform) => {
// HACK: manually resolve a few packages that use `exports` in `package.json`.
// A proper solution is to enable `unstable_enablePackageExports` but this needs careful testing.
if (moduleName.startsWith('multiformats/hashes/hasher')) {
return context.resolveRequest(
context,
'multiformats/cjs/src/hashes/hasher',
platform,
)
}
if (moduleName.startsWith('multiformats/cid')) {
return context.resolveRequest(context, 'multiformats/cjs/src/cid', platform)
}
if (moduleName === '@ipld/dag-cbor') {
return context.resolveRequest(context, '@ipld/dag-cbor/src', platform)
}
if (process.env.BSKY_PROFILE) {
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
return context.resolveRequest(
@@ -51,16 +34,6 @@ cfg.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
nonInlinedRequires: [
// We can remove this option and rely on the default after
// https://github.com/facebook/metro/pull/1390 is released.
'React',
'react',
'react-compiler-runtime',
'react/jsx-dev-runtime',
'react/jsx-runtime',
'react-native',
],
},
})
+134
View File
@@ -0,0 +1,134 @@
# BlueskyClip
An iOS App Clip implementation for Bluesky starter packs. App Clips are lightweight app experiences that allow users to preview and join Bluesky through starter packs without installing the full app.
## What It Does
BlueskyClip provides a minimal, on-demand iOS app experience for viewing and joining Bluesky starter packs. When a user encounters a starter pack link (e.g., `bsky.app/start/...` or `go.bsky.app/...`), iOS can present the App Clip instead of requiring a full app install. The App Clip:
1. Loads the starter pack web page in a WKWebView
2. Allows users to browse the starter pack content
3. Presents the App Store overlay when the user decides to join
4. Passes the starter pack URI to the main app via shared UserDefaults
## Architecture
### Native iOS Implementation
The App Clip is a standalone iOS target with its own minimal Swift implementation:
- **AppDelegate.swift**: Standard app delegate that sets up the view controller and handles URL routing (both direct URL opens and universal links)
- **ViewController.swift**: Main view controller that manages the WKWebView, detects starter pack URLs, and communicates with the web layer
### Communication Flow
```
User taps starter pack link
iOS presents BlueskyClip App Clip
WKWebView loads bsky.app with ?clip=true parameter
Web app detects clip mode and sends actions via postMessage
ViewController receives messages and:
- Presents App Store overlay (action: "present")
- Stores starter pack URI in shared UserDefaults (action: "store")
User downloads main app
Main app reads starterPackUri from shared UserDefaults
Main app displays starter pack onboarding flow
```
### Key Implementation Details
**URL Detection** (`isStarterPackUrl`):
- Matches `bsky.app/start/*` and `bsky.app/starter-pack/*` paths (4 path components)
- Matches short links `go.bsky.app/*` (2 path components)
**WebView Communication** (`WKScriptMessageHandler`):
- Listens for messages on the "onMessage" channel
- Handles two action types:
- `present`: Shows the App Store overlay using `SKOverlay`
- `store`: Writes JSON data to shared UserDefaults with the specified key
**Data Sharing**:
- Uses UserDefaults suite `group.app.bsky` (App Group)
- Primary key: `starterPackUri` - stores the starter pack URL
- The main app reads this value on launch via `SharedPrefs.getString('starterPackUri')` (see `src/components/hooks/useStarterPackEntry.native.ts`)
## Configuration
### Build Configuration
The App Clip target is automatically configured via Expo config plugins located in `/plugins/starterPackAppClipExtension/`:
- **withStarterPackAppClip.js**: Main plugin that orchestrates all configuration
- **withXcodeTarget.js**: Creates the App Clip target in Xcode with proper build settings
- **withAppEntitlements.js**: Configures main app entitlements for App Clip association
- **withClipEntitlements.js**: Sets up App Clip entitlements (App Groups, parent app identifier, associated domains)
- **withClipInfoPlist.js**: Generates the Info.plist for the App Clip target
- **withFiles.js**: Copies Swift source files and assets from `modules/BlueskyClip/` to the iOS build directory
### Entitlements
**Main App** (`app.entitlements`):
- `com.apple.security.application-groups`: `group.app.bsky`
- `com.apple.developer.associated-appclip-app-identifiers`: Links to the App Clip bundle ID
**App Clip** (`BlueskyClip.entitlements`):
- `com.apple.security.application-groups`: `group.app.bsky` (for data sharing)
- `com.apple.developer.parent-application-identifiers`: Links to the main app bundle ID
- `com.apple.developer.associated-domains`: Inherits from main app config (for universal links)
### Build Settings
- Deployment target: iOS 15.1+
- Bundle ID: `[main-app-bundle-id].AppClip`
- Product type: `com.apple.product-type.application.on-demand-install-capable`
- Development team: `B3LX46C5HS`
- Device family: iPhone only (1)
## Platform Support
- **iOS**: Full support via native App Clip
- **Android**: Not applicable (no App Clip equivalent)
- **Web**: Not applicable (web uses standard starter pack landing pages)
## Integration with Main App
The main app detects App Clip-originated starter packs through `useStarterPackEntry` hook:
**Native** (`src/components/hooks/useStarterPackEntry.native.ts`):
- Reads `starterPackUri` from `SharedPrefs` (App Group)
- Clears the value after reading to prevent re-use
- Sets active starter pack in app state
**Web** (`src/components/hooks/useStarterPackEntry.ts`):
- Detects `?clip=true` URL parameter
- Extracts starter pack URI from URL
- Sets active starter pack with `isClip: true` flag
## Files
```
modules/BlueskyClip/
├── AppDelegate.swift # App lifecycle and URL handling
├── ViewController.swift # WebView management and message handling
└── Images.xcassets/ # App Clip icon assets
├── AppIcon.appiconset/
│ ├── App-Icon-1024x1024@1x.png
│ └── Contents.json
└── Contents.json
```
## Development Notes
- The App Clip is built as part of the main Xcode project when running `pnpm prebuild`
- Source files are copied during the prebuild process, not directly referenced
- Changes to Swift files require running `pnpm prebuild` to take effect
- The App Clip shares the same version number as the main app
- App Clips have a 15MB size limit (enforced by Apple)
- Users can convert an App Clip session into a full app install without losing data (via shared App Group)
+7
View File
@@ -8,6 +8,13 @@
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
+147 -7
View File
@@ -1,5 +1,6 @@
import UserNotifications
import UIKit
import Intents
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,33 @@ class NotificationService: UNNotificationServiceExtension {
}
self.bestAttempt = bestAttempt
if reason == "chat-message" {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
// Only apply the title->body swap for chat-message. For chat-reaction,
// `messageKind` refers to the reacted-to message, so we'd clobber the
// descriptive reaction body with the title for reactions on system
// messages.
if reason == "chat-message" {
mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo)
}
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else if reason == "chat-added-to-group"
|| reason == "chat-removed-from-group"
|| reason == "chat-join-request-rejected" {
mutateWithChatMessage(bestAttempt)
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
}
// Any image downloading (or other network tasks) should be handled at the end
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
// gets called, we might not have all the needed mutations completed in time.
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
@@ -61,6 +78,97 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt)
}
// MARK: Communication Notification
func createCommunicationNotification(
from content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage? = nil
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
let senderHandleValue = userInfo["senderHandle"] as? String
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
let sender = INPerson(
personHandle: senderHandle,
nameComponents: nil,
displayName: senderDisplayName,
image: avatarImage,
contactIdentifier: nil,
customIdentifier: nil
)
var speakableGroupName: INSpeakableString? = nil
if userInfo["convoKind"] as? String == "group",
let groupName = userInfo["convoGroupName"] as? String,
!groupName.isEmpty {
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: speakableGroupName,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
// For group convos, attach the composite group avatar (rendered by the
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
// alongside the sender on the Communication Notification.
if userInfo["convoKind"] as? String == "group",
let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
return try content.updating(from: intent)
} catch {
return content
}
}
func downloadAvatarImage(from urlString: String) -> INImage? {
let thumbnailUrlString = urlString.replacingOccurrences(
of: "/img/avatar/",
with: "/img/avatar_thumbnail/"
)
guard let url = URL(string: thumbnailUrlString) else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
}
task.resume()
semaphore.wait()
guard let data = imageData else { return nil }
return INImage(imageData: data)
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
@@ -80,6 +188,38 @@ class NotificationService: UNNotificationServiceExtension {
}
}
// For group convos, surface the group name as the notification subtitle.
// The sender's display name is shown as the title (overridden by
// `INSendMessageIntent` for chat-message/chat-reaction).
func mutateWithGroupSubtitle(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard userInfo["convoKind"] as? String == "group",
let groupName = userInfo["convoGroupName"] as? String,
!groupName.isEmpty else {
return
}
content.subtitle = groupName
}
// System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are
// delivered through `chat-message` but have a server-rendered description
// in `title`. iOS overrides the title with the sender name once we apply
// `INSendMessageIntent`, so we move the title text into the body before
// building the intent.
func mutateChatMessageBody(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard let messageKind = userInfo["messageKind"] as? String,
messageKind != "message",
!content.title.isEmpty else {
return
}
content.body = content.title
}
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default
}
+135
View File
@@ -0,0 +1,135 @@
# BlueskyNSE
BlueskyNSE is an iOS Notification Service Extension that processes push notifications before they are displayed to the user. NSE stands for "Notification Service Extension", a native iOS app extension type.
## What It Does
This extension intercepts incoming push notifications and performs processing before displaying them:
1. Manages badge counts for app icon
2. Applies custom notification sounds based on user preferences
3. Enables notification customization without requiring the main app to be running
## How It Works
When a push notification arrives on iOS, the system can invoke this extension to modify the notification content before displaying it. The extension runs in a separate process from the main app and has strict time limits (approximately 30 seconds) to complete its work.
### Architecture
The extension uses shared UserDefaults (via App Groups) to access preferences set by the main app:
- **App Group**: `group.app.bsky` allows data sharing between the main app and the extension
- **Shared Preferences**: Stored in UserDefaults suite accessible by both processes
- **Thread Safety**: Uses a dedicated serial DispatchQueue (`NSEPrefsQueue`) to prevent race conditions when multiple notifications arrive simultaneously
### Notification Processing Flow
1. System receives push notification
2. `NotificationService.didReceive()` is called
3. Extension creates mutable copy of notification content
4. Based on notification type (determined by `reason` field):
- **Chat messages** (`reason == "chat-message"`): Applies custom DM sound if user preference `playSoundChat` is enabled
- **Other notifications**: Increments and applies badge count
5. Extension delivers modified notification to system via `contentHandler`
### Badge Count Management
Badge counts are managed centrally by the extension:
- Each non-chat notification increments the badge count
- Count is synchronized across notification instances using the serial queue
- Main app can reset the count via the `expo-background-notification-handler` module
### Notification Sounds
Two sound types are supported:
- **Default system sound**: Standard iOS notification sound
- **DM sound**: Custom `dm.aiff` sound file for chat messages
DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
## Key Files
| File | Purpose |
|------|---------|
| `NotificationService.swift` | Main service extension implementation |
| `BlueskyNSE.entitlements` | iOS entitlements configuration for App Group access |
| `Info.plist` | Extension metadata and configuration |
### NotificationService.swift
Contains two main classes:
**NotificationService**: The main extension class that implements `UNNotificationServiceExtension`
- `didReceive(_:withContentHandler:)`: Processes incoming notifications
- `serviceExtensionTimeWillExpire()`: Handles timeout scenarios
- Mutation methods for modifying notification content
**NSEUtil**: Singleton utility class for shared state management
- Provides shared `UserDefaults` instance for the App Group
- Manages serial queue for thread-safe preference access
- Helper methods for notification content manipulation
## Configuration
### App Group Setup
The extension requires the `group.app.bsky` App Group to be configured in:
1. Main app target capabilities
2. Extension target capabilities (defined in `BlueskyNSE.entitlements`)
### Shared Preferences
The following preferences are shared between the main app and extension:
| Preference Key | Type | Purpose |
|----------------|------|---------|
| `badgeCount` | Int | Current badge count for app icon |
| `playSoundChat` | Bool | Whether to play sound for chat notifications |
These are managed by the `expo-background-notification-handler` module in the main app.
### Sound Files
The custom DM sound file (`dm.aiff`) must be included in the extension's bundle. The iOS project configuration handles copying this resource during the build.
## Platform Support
- **iOS**: Fully supported (primary platform for this extension)
- **Android**: Not applicable (Android uses different notification handling mechanisms)
- **Web**: Not applicable (web notifications are handled by browser APIs)
## Integration with Main App
The extension coordinates with the main app through:
1. **expo-background-notification-handler** module: Provides JavaScript API for managing shared preferences
2. **App Group shared storage**: Enables data synchronization between processes
3. **Push notification payload**: Must include `reason` field to determine notification type
### Setting User Preferences
Users can control notification sounds via the Chat Settings screen (`src/screens/Messages/Settings.tsx`):
```typescript
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
const {preferences, setPref} = useBackgroundNotificationPreferences()
setPref('playSoundChat', true) // Enable DM sounds
```
## Limitations
1. **Time constraints**: Extension must complete processing within ~30 seconds or the system will terminate it
2. **Process isolation**: Runs in separate process with limited memory and resources
3. **iOS only**: Notification Service Extensions are an iOS-specific feature
4. **Concurrent processing**: Multiple notifications may arrive simultaneously, requiring careful state management
## Best Practices
When modifying this extension:
1. Keep processing fast and synchronous when possible
2. Use the shared serial queue for any UserDefaults mutations
3. Avoid network requests that could cause timeouts
4. Always call `contentHandler` with modified content, even on errors
5. Test with multiple concurrent notifications to verify thread safety
+140
View File
@@ -0,0 +1,140 @@
# Share-with-Bluesky
iOS Share Extension for the Bluesky Social app that enables users to share content from other apps directly to Bluesky.
## Overview
This module implements an iOS Share Extension (Action Extension) that appears in the system share sheet when users tap the share button in other iOS apps. It allows sharing text, URLs, images, and videos to create a new Bluesky post.
## Features
- Share plain text
- Share URLs (web links)
- Share images (up to 4 images, supports PNG, JPG, JPEG, GIF, HEIC)
- Share videos (single video, supports MOV, MP4, M4V)
- Automatic image dimension extraction
- Automatic video dimension extraction
- App group file sharing for media access
## Architecture
### iOS Share Extension
The extension is implemented as a native iOS Share Extension using Swift. When a user shares content:
1. The `ShareViewController` receives the shared content from the extension context
2. Content is processed based on its type (text, URL, image, or video)
3. Media files are copied to a shared App Group container (`group.app.bsky`) for access by the main app
4. Image and video dimensions are extracted and encoded into the URI
5. The extension constructs a deep link URL with the content encoded in query parameters
6. The main Bluesky app is opened with the deep link
7. The extension completes and dismisses
### Deep Link Format
The extension communicates with the main app using deep links with the `bluesky://` scheme:
```
bluesky://intent/compose?text=<encoded-text>
bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>
bluesky://intent/compose?videoUri=<uri>|<width>|<height>
```
The scheme can be customized by setting the `MainAppScheme` key in `Info.plist` to support forks.
### Main App Integration
The main app handles these deep links in `src/lib/hooks/useIntentHandler.ts`:
- Parses the deep link parameters
- Validates image/video URIs for security (filters out external URLs)
- Opens the composer with the pre-populated content
- Supports up to 4 images or 1 video per share
## Key Files
### Module Files
- `ShareViewController.swift` - Main view controller that handles share requests and processes content
- `Info.plist` - Extension configuration (activation rules, supported content types)
- `Share-with-Bluesky.entitlements` - App group entitlements for shared file access
### App Integration
- `src/lib/hooks/useIntentHandler.ts` - Main app hook that handles incoming deep links
- `android/app/src/main/AndroidManifest.xml` - Android share intent configuration (lines 57-76)
## Configuration
### Supported Content Types
Defined in `Info.plist` under `NSExtensionActivationRule`:
- Text: Plain text strings
- Web URLs: Up to 1 URL
- Images: Up to 10 images
- Videos: Up to 1 video
### App Group
The extension uses the `group.app.bsky` App Group identifier to share files with the main app. This is configured in:
- `Share-with-Bluesky.entitlements`
- Main app's entitlements file
### Custom Scheme
The `MainAppScheme` in `Info.plist` defaults to `bluesky` but can be changed for forks to use a custom URL scheme.
## Platform Support
- iOS: Native Share Extension (this module)
- Android: Native share intents handled via MainActivity intent filters in AndroidManifest.xml
- Web: Not applicable (browser share APIs use different mechanisms)
## Implementation Details
### Image Processing
When images are shared:
1. Images are loaded from the extension's temporary directory or as UIImage objects
2. Images are converted to JPEG format at maximum quality
3. Dimensions are extracted from the UIImage
4. Files are saved to the App Group container with unique names
5. URIs are formatted as `<file-url>|<width>|<height>`
### Video Processing
When videos are shared:
1. Videos are copied from the source URL to the App Group container
2. AVURLAsset is used to extract video track dimensions
3. Track dimensions are adjusted for video rotation using preferredTransform
4. URI is formatted as `<file-url>|<width>|<height>`
### Security
- External URLs in image URIs are filtered out in the main app to prevent potential security issues
- Only file:// URLs from the App Group container are accepted
- URI format is validated with a regex pattern before processing
## Development
This module is built as part of the main Xcode project. The extension target is included in the iOS build configuration.
To modify the extension:
1. Open the Xcode project in `/ios`
2. Navigate to the Share-with-Bluesky target
3. Edit `ShareViewController.swift` for logic changes
4. Edit `Info.plist` for configuration changes
5. Rebuild the iOS app
## Limitations
- Images: Maximum of 4 images per share (limited in main app handler)
- Videos: Only 1 video per share
- Mixed media: Cannot share images and videos together
- File size: No explicit limits, but large files may cause issues
- Formats: Only supports common image/video formats listed in constants
+248
View File
@@ -0,0 +1,248 @@
# Bottom Sheet Expo Module
A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android).
## Overview
This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior.
Key features:
- Native bottom sheet presentation on iOS and Android
- Automatic content height detection (no JS bridge round-trip)
- Configurable snap points (hidden, partial, full)
- Drag-to-dismiss with prevention controls
- Portal-based rendering for proper z-index layering
- Edge-to-edge support on modern Android versions
- iOS 26+ zoom transition support
## Platform Support
- **iOS**: Uses `UISheetPresentationController` (iOS 15+)
- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior`
- **Web**: Not supported (throws error)
## Architecture
### TypeScript Layer
The module exposes a React component that handles rendering and state management:
- **BottomSheet.tsx** (Native): Main component wrapping the native view
- **BottomSheet.web.tsx** (Web): Stub that throws an error
- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration
- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content
- **Portal.tsx**: Generic portal implementation for managing component hierarchy
The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`).
### Native Layer
#### iOS Implementation
- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings
- **SheetView.swift**: Main view component that creates and manages `SheetViewController`
- Observes content height via KVO (Key-Value Observing) on bounds
- Manages sheet lifecycle and state transitions
- Implements `UISheetPresentationControllerDelegate` for drag events
- **SheetViewController.swift**: UIViewController subclass with sheet presentation
- Configures detents (snap points) based on content height
- Handles iOS 26+ safe area adjustments for floating sheet style
- Animates detent changes when content resizes
- **SheetManager.swift**: Singleton that tracks all active sheets with weak references
- **Util.swift**: Helper for calculating screen height minus safe area insets
#### Android Implementation
- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality
- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog
- Uses `OnLayoutChangeListener` to observe content height natively
- Configures `BottomSheetBehavior` for drag and snap behavior
- Handles edge-to-edge display across Android versions (API 29-35+)
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
### Content Height Detection
Both platforms detect content height changes natively without JS bridge round-trips:
- **iOS**: KVO observation on the content view's `bounds` property
- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls)
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
## Props
```typescript
interface BottomSheetViewProps {
children: React.ReactNode
// Appearance
cornerRadius?: number
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
// Behavior
preventDismiss?: boolean // Disable swipe-to-dismiss
preventExpansion?: boolean // Lock to initial height (no full-screen)
disableDrag?: boolean // Disable drag handle (Android only)
fullHeight?: boolean // Start at full screen height
// Height constraints
minHeight?: number // Minimum height in dp
maxHeight?: number // Maximum height in dp
// iOS 26+ transition
sourceViewTag?: number // View tag for zoom transition origin
// Events
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
onStateChange?: (event: BottomSheetStateChangeEvent) => void
}
```
## States and Snap Points
### States
- `closed`: Sheet is dismissed
- `closing`: Sheet is animating closed
- `open`: Sheet is fully visible
- `opening`: Sheet is animating open
### Snap Points
- `Hidden` (0): Dismissed
- `Partial` (1): Half-expanded / content height
- `Full` (2): Expanded to screen height
## Usage
### Basic Example
```tsx
import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
// In your app root:
function App() {
return (
<BottomSheetProvider>
<YourApp />
<BottomSheetOutlet />
</BottomSheetProvider>
)
}
// In a component:
function MyComponent() {
const sheetRef = useRef<BottomSheet>(null)
const openSheet = () => {
sheetRef.current?.present()
}
const closeSheet = () => {
sheetRef.current?.dismiss()
}
return (
<>
<Button onPress={openSheet} title="Open Sheet" />
<BottomSheet
ref={sheetRef}
cornerRadius={16}
backgroundColor="white"
onStateChange={(e) => console.log(e.nativeEvent.state)}
>
<View style={{padding: 20}}>
<Text>Sheet content</Text>
<Button onPress={closeSheet} title="Close" />
</View>
</BottomSheet>
</>
)
}
```
### Nested Sheets
The module supports nesting sheets by using `BottomSheetPortalProvider` within sheet content:
```tsx
<BottomSheet ref={outerSheetRef}>
<BottomSheetPortalProvider>
<Button onPress={() => innerSheetRef.current?.present()} />
<BottomSheet ref={innerSheetRef}>
<Text>Inner sheet content</Text>
</BottomSheet>
</BottomSheetPortalProvider>
</BottomSheet>
```
### Dismiss All Sheets
```tsx
import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
BottomSheetNativeComponent.dismissAll()
```
## Key Implementation Details
### iOS Specific
1. **iOS 15 Compatibility**: On iOS 15, custom detents are not available, so the module uses `.medium()` detent and applies extra styling to prevent visual issues.
2. **iOS 26+ Zoom Transitions**: When `sourceViewTag` is provided on iOS 26+, the sheet zooms from the specified view.
3. **Detent Selection**: The module automatically chooses between custom detents, `.medium()`, and `.large()` based on content height and screen size.
### Android Specific
1. **Edge-to-Edge**: The module handles edge-to-edge display correctly across API levels:
- API 35+: Mandatory edge-to-edge
- API 30-34: Uses `currentWindowMetrics`
- API <30: Uses deprecated `getRealSize()`
2. **Status/Nav Bar Appearance**: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
3. **Drag Handling**: On full-height sheets with `preventDismiss`, dragging is disabled to prevent accidental dismissal (since there's no half-expanded snap point to land on).
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
- **disableDrag**: Android-only prop (iOS drag behavior is controlled via `preventDismiss` + `preventExpansion`)
- **sourceViewTag**: iOS 26+ only (ignored on Android)
## Files Reference
### TypeScript
- `index.ts` - Public API exports
- `src/BottomSheet.types.ts` - TypeScript type definitions
- `src/BottomSheet.tsx` - Native component (re-export)
- `src/BottomSheet.web.tsx` - Web stub
- `src/BottomSheetNativeComponent.tsx` - Native wrapper with portal integration
- `src/BottomSheetNativeComponent.web.tsx` - Web stub for native component
- `src/BottomSheetPortal.tsx` - Portal context and providers
- `src/lib/Portal.tsx` - Generic portal implementation
### iOS
- `ios/BottomSheetModule.swift` - Module definition
- `ios/SheetView.swift` - Main view implementation
- `ios/SheetViewController.swift` - View controller for sheet presentation
- `ios/SheetManager.swift` - Singleton for tracking active sheets
- `ios/Util.swift` - Screen height utility
### Android
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt` - Module definition
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt` - Main view implementation
- `android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt` - Dialog root view group
- `android/src/main/java/expo/modules/bottomsheet/SheetManager.kt` - Sheet tracking singleton
### Configuration
- `expo-module.config.json` - Expo module configuration
@@ -0,0 +1,162 @@
# expo-background-notification-handler
A custom Expo module for managing shared notification preferences and handling background notifications in the Bluesky Social app. This module enables communication between the main app and notification service extensions through shared storage.
## Purpose
This module solves a critical problem in native notification handling: notification service extensions run in a separate process from the main app and cannot directly access React Native state or APIs. The module provides a bridge by storing notification preferences in shared storage that both the main app and notification service extension can access.
The primary use case is storing user preferences (like notification sound settings) while the app is foregrounded or backgrounded, minimizing the need for background fetches when processing notifications.
## Platform Support
- **iOS**: Full support via UserDefaults with App Groups
- **Android**: Full support via SharedPreferences
- **Web**: Stub implementation (no-op)
## Architecture
### iOS Implementation
Uses iOS App Groups (`group.app.bsky`) to share UserDefaults between the main app and the notification service extension. This allows the notification service extension to read preferences set by the main app without launching the app.
**Key Files:**
- `ios/ExpoBackgroundNotificationHandlerModule.swift` - Native module implementation
- `ios/ExpoBackgroundNotificationHandler.podspec` - CocoaPods specification
### Android Implementation
Uses SharedPreferences with Firebase Cloud Messaging (FCM) to handle background notifications. The module tracks app foreground/background state and conditionally processes notifications based on whether the app is foregrounded.
**Key Files:**
- `android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt` - Expo module definition
- `android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt` - SharedPreferences wrapper
- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt` - Notification processing logic
- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt` - Interface for showing notifications
- `android/build.gradle` - Build configuration
### TypeScript/React API
**Key Files:**
- `index.ts` - Module entry point
- `src/ExpoBackgroundNotificationHandlerModule.ts` - Native module binding (iOS/Android)
- `src/ExpoBackgroundNotificationHandlerModule.web.ts` - Web stub
- `src/ExpoBackgroundNotificationHandler.types.ts` - TypeScript type definitions
- `src/BackgroundNotificationHandlerProvider.tsx` - React Context provider for preferences
## Stored Preferences
The module manages the following notification preferences:
```typescript
{
playSoundChat: boolean, // Currently exposed to TypeScript
playSoundFollow: boolean, // Native only (not yet exposed)
playSoundLike: boolean, // Native only (not yet exposed)
playSoundMention: boolean, // Native only (not yet exposed)
playSoundQuote: boolean, // Native only (not yet exposed)
playSoundReply: boolean, // Native only (not yet exposed)
playSoundRepost: boolean, // Native only (not yet exposed)
mutedThreads: [String: [String]], // iOS only
badgeCount: number // iOS only
}
```
Default values are initialized when the module is created, with most sound preferences defaulting to `false` except `playSoundChat` which defaults to `true`.
## API
### Core Methods
```typescript
// Get all preferences
getAllPrefsAsync(): Promise<BackgroundNotificationHandlerPreferences>
// Get individual values
getBoolAsync(forKey: string): Promise<boolean>
getStringAsync(forKey: string): Promise<string>
getStringArrayAsync(forKey: string): Promise<string[]>
// Set individual values
setBoolAsync(forKey: string, value: boolean): Promise<void>
setStringAsync(forKey: string, value: string): Promise<void>
setStringArrayAsync(forKey: string, value: string[]): Promise<void>
// Array manipulation
addToStringArrayAsync(forKey: string, value: string): Promise<void>
removeFromStringArrayAsync(forKey: string, value: string): Promise<void>
addManyToStringArrayAsync(forKey: string, value: string[]): Promise<void>
removeManyFromStringArrayAsync(forKey: string, value: string[]): Promise<void>
// Badge count (iOS only)
setBadgeCountAsync(count: number): Promise<void>
```
### React Context API
The module provides a React Context provider for managing preferences in the app:
```typescript
import {
BackgroundNotificationPreferencesProvider,
useBackgroundNotificationPreferences,
} from 'expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
function App() {
return (
<BackgroundNotificationPreferencesProvider>
<YourApp />
</BackgroundNotificationPreferencesProvider>
)
}
function SettingsScreen() {
const {preferences, setPref} = useBackgroundNotificationPreferences()
return (
<Toggle
value={preferences.playSoundChat}
onValueChange={(value) => setPref('playSoundChat', value)}
/>
)
}
```
## Android Notification Handling
The Android implementation includes logic for processing notifications while the app is backgrounded:
- **Chat messages**: Applies custom notification channels based on `playSoundChat` preference
- Sound enabled: Uses `chat-messages` channel (or `dm.mp3` sound on older Android)
- Sound disabled: Uses `chat-messages-muted` channel
- **Other notification types**: On Android Oreo+ (API 26+), assigns notifications to channels based on reason:
- Supported reasons: `like`, `repost`, `follow`, `mention`, `reply`, `quote`, `like-via-repost`, `repost-via-repost`, `subscribed-post`
- Each reason maps to its corresponding notification channel
When the app is foregrounded, the module defers to `expo-notifications` for notification handling.
## Configuration
### iOS
Requires App Group entitlement configured in Xcode:
- App Group ID: `group.app.bsky`
### Android
Requires Firebase Cloud Messaging (FCM) integration:
- Dependency: `com.google.firebase:firebase-messaging-ktx:24.0.0`
- SharedPreferences name: `xyz.blueskyweb.app`
## Usage in the App
The module is used to:
1. Store notification preferences that need to be accessed by notification service extensions
2. Track app foreground/background state on Android
3. Process and mutate notification payloads based on user preferences before display
4. Manage notification badge counts on iOS
5. Handle thread muting and other notification filtering logic
By keeping preferences in shared storage, the notification service extension can make intelligent decisions about notification presentation without waking up the React Native runtime or making network requests.
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return
}
if (remoteMessage.data["reason"] == "chat-message") {
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
+167
View File
@@ -0,0 +1,167 @@
# expo-bluesky-gif-view
An Expo module for displaying animated GIFs and WebP images with optimized performance and playback controls.
## Overview
This module provides a custom view component for rendering animated GIFs with support for:
- Autoplay control
- Placeholder images while loading
- Programmatic playback control (play/pause/toggle)
- Image prefetching
- Efficient memory management
- Player state change events
## Platform Support
- iOS (13.4+)
- Android (API 21+)
- Web
## Architecture
The module uses native platform libraries for optimal GIF rendering performance:
### iOS Implementation
- **Library**: SDWebImage with SDWebImageWebPCoder
- **Key Files**:
- `ios/GifView.swift` - Main view implementation using `SDAnimatedImageView`
- `ios/ExpoBlueskyGifViewModule.swift` - Module definition and prop bindings
- `ios/Util.swift` - Cache configuration utilities
**Approach**: Uses `SDAnimatedImageView` for hardware-accelerated GIF rendering. Images are cached to disk only (not memory) to avoid performance issues with `SDAnimatedImage` when loaded from memory. The view automatically cancels pending requests when scrolled off-screen and resumes loading when visible.
### Android Implementation
- **Library**: Glide
- **Key Files**:
- `android/src/main/java/expo/modules/blueskygifview/GifView.kt` - Main view implementation
- `android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt` - Module definition
- `android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt` - Custom ImageView with playback control
**Approach**: Uses Glide's disk cache strategy for loading animated GIFs. Placeholders are loaded with `skipMemoryCache(true)` to avoid cache bloat. The custom `AppCompatImageViewExtended` detects when animations are loaded via `onDraw` and manages the `Animatable` drawable lifecycle.
### Web Implementation
- **Library**: Native HTML5 `<video>` element
- **Key File**: `src/GifView.web.tsx`
**Approach**: Uses a looping, muted video element to display GIFs. This provides better performance than image-based approaches on the web. The implementation tracks load state to fire the `onPlayerStateChange` event only once (since `onCanPlay` fires on every loop).
## Usage
```tsx
import {GifView} from 'expo-bluesky-gif-view'
function MyComponent() {
const gifRef = React.useRef<GifView>(null)
return (
<GifView
source="https://example.com/animated.gif"
placeholderSource="https://example.com/thumbnail.jpg"
autoplay={true}
onPlayerStateChange={(event) => {
console.log('Playing:', event.nativeEvent.isPlaying)
console.log('Loaded:', event.nativeEvent.isLoaded)
}}
ref={gifRef}
/>
)
}
```
## API
### Props
- `source?: string` - URL of the animated GIF/WebP
- `placeholderSource?: string` - URL of a static placeholder image to show while loading
- `autoplay?: boolean` - Whether to start playing automatically (default: true)
- `onPlayerStateChange?: (event: GifViewStateChangeEvent) => void` - Callback fired when playback state changes
### Methods
All methods are async and return a Promise:
```tsx
await gifRef.current?.playAsync()
await gifRef.current?.pauseAsync()
await gifRef.current?.toggleAsync()
```
### Static Methods
```tsx
// Prefetch GIFs into the cache (not supported on web)
await GifView.prefetchAsync([
'https://example.com/gif1.gif',
'https://example.com/gif2.gif'
])
```
## Configuration
### iOS Dependencies
The module requires SDWebImage and SDWebImageWebPCoder:
```ruby
# ios/ExpoBlueskyGifView.podspec
s.dependency 'SDWebImage', '~> 5.21.0'
s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
```
### Android Dependencies
The module uses Glide, kept in sync with expo-image version:
```gradle
# android/build.gradle
implementation 'com.github.bumptech.glide:glide:4.13.2'
```
## Key Implementation Details
### Lifecycle Management
- **iOS**: Cancels pending requests in `willMove(toWindow:)` when scrolled off-screen
- **Android**: Pauses playback in `onDetachedFromWindow()`, resumes in `onAttachedToWindow()`
- **Web**: Uses React lifecycle methods to manage video element state
### Cache Strategy
- **iOS**: Disk-only caching to work around `SDAnimatedImage` memory issues
- **Android**: DATA disk cache for main images, skips memory cache for placeholders
- **Web**: Relies on browser cache
### Animation Control
- **iOS**: `SDAnimatedImageView.autoPlayAnimatedImage` is explicitly set to false to prevent automatic animation on viewport entry
- **Android**: Custom `AppCompatImageViewExtended` manages `Animatable` drawable state
- **Web**: Uses HTMLMediaElement play/pause APIs
## Files Overview
```
expo-bluesky-gif-view/
├── index.ts # Module entry point
├── expo-module.config.json # Expo module configuration
├── src/
│ ├── GifView.types.ts # TypeScript type definitions
│ ├── GifView.tsx # Native implementation (iOS/Android)
│ └── GifView.web.tsx # Web implementation
├── ios/
│ ├── ExpoBlueskyGifView.podspec # CocoaPods spec
│ ├── ExpoBlueskyGifViewModule.swift # Module and prop definitions
│ ├── GifView.swift # iOS view implementation
│ └── Util.swift # Cache configuration
└── android/
├── build.gradle # Gradle build configuration
└── src/main/java/expo/modules/blueskygifview/
├── ExpoBlueskyGifViewModule.kt # Module and prop definitions
├── GifView.kt # Android view implementation
└── AppCompatImageViewExtended.kt # Custom ImageView for playback
```
@@ -1,4 +1,4 @@
import {ViewProps} from 'react-native'
import {type ViewProps} from 'react-native'
export interface GifViewStateChangeEvent {
nativeEvent: {
@@ -10,6 +10,12 @@ export interface GifViewStateChangeEvent {
export interface GifViewProps extends ViewProps {
autoplay?: boolean
source?: string
/**
* Web-only ordered list of `<source>` tags rendered inside `<video>`. The
* browser uses `canPlayType` to pick the first one it supports. Ignored on
* native (which uses `source` directly).
*/
sources?: ReadonlyArray<{src: string; type: string}>
placeholderSource?: string
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
}
@@ -1,17 +1,20 @@
import * as React from 'react'
import {createRef, PureComponent, type RefObject} from 'react'
import {StyleSheet} from 'react-native'
import {GifViewProps} from './GifView.types'
import {type GifViewProps} from './GifView.types'
export class GifView extends React.PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
React.createRef()
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
componentDidMount() {
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
@@ -22,10 +25,28 @@ export class GifView extends React.PureComponent<GifViewProps> {
}
}
componentWillUnmount() {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
}
static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web')
}
// Safari pauses backgrounded `<video>` elements when the tab becomes
// inactive and does not resume them automatically when the tab is shown
// again, leaving GIFs frozen on a still frame. Resume playback when the
// page becomes visible if the consumer expects autoplay.
private onVisibilityChange = () => {
if (
document.visibilityState === 'visible' &&
this.props.autoplay &&
this.videoPlayerRef.current?.paused
) {
void this.playAsync()
}
}
private firePlayerStateChangeEvent = () => {
this.props.onPlayerStateChange?.({
nativeEvent: {
@@ -62,21 +83,29 @@ export class GifView extends React.PureComponent<GifViewProps> {
}
render() {
const {sources, source, autoplay, accessibilityLabel, style} = this.props
const useSources = sources && sources.length > 0
return (
<video
src={this.props.source}
autoPlay={this.props.autoplay ? 'autoplay' : undefined}
preload={this.props.autoplay ? 'auto' : undefined}
// 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}
preload={autoplay ? 'auto' : undefined}
playsInline={true}
loop="loop"
muted="muted"
style={StyleSheet.flatten(this.props.style)}
style={StyleSheet.flatten(style)}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
aria-label={this.props.accessibilityLabel}
ref={this.videoPlayerRef}
/>
aria-label={accessibilityLabel}
ref={this.videoPlayerRef}>
{useSources
? sources.map(s => <source key={s.src} src={s.src} type={s.type} />)
: null}
</video>
)
}
}
+231
View File
@@ -0,0 +1,231 @@
# expo-bluesky-swiss-army
A collection of native utilities for the Bluesky Social app. This Expo module provides platform-specific functionality that is not available through standard React Native APIs.
## Overview
This module consolidates several native features into a single Expo module:
- **PlatformInfo**: Platform-specific accessibility and audio session management
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
- **VisibilityView**: A native view component that tracks which view is currently visible on screen
## Modules
### PlatformInfo
Provides platform-specific information and audio session control.
**Functions:**
- `getIsReducedMotionEnabled(): boolean` - Returns whether the user has enabled reduced motion in system settings. Works on all platforms (iOS uses UIAccessibility, Android checks transition animation scale, Web checks CSS media query).
- `setAudioActive(active: boolean): void` - iOS only. Controls whether the app's audio session is active. When deactivated with `false`, it notifies other apps to resume their audio playback.
- `setAudioCategory(category: AudioCategory): void` - iOS only. Sets the AVAudioSession category. Use `AudioCategory.Playback` for video/music playback and `AudioCategory.Ambient` for audio that mixes with other apps.
**Platform Support:**
- iOS: Full support for all functions
- Android: `getIsReducedMotionEnabled()` only
- Web: `getIsReducedMotionEnabled()` only
### Referrer
Tracks how users arrive at the app from external sources.
**Functions:**
- `getReferrerInfo(): ReferrerInfo | null` - Returns information about the source that launched the app. Returns `{referrer: string, hostname: string}` or `null`.
- **iOS**: Reads from SharedPrefs (set by app extensions or deep link handlers)
- **Android**: Extracts referrer from Intent extras or activity referrer
- **Web**: Parses `document.referrer` (excludes bsky.app domain)
- `getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo>` - Android only. Retrieves Google Play install referrer information including install timestamp and click timestamp. Uses the Google Play Install Referrer API.
**Platform Support:**
- iOS: `getReferrerInfo()` only (reads from SharedPrefs)
- Android: Both functions
- Web: `getReferrerInfo()` only
### SharedPrefs
Native key-value storage that persists across app restarts. Uses iOS App Groups (`group.app.bsky`) for sharing data with extensions, and Android SharedPreferences.
**Functions:**
- `setValue(key: string, value: string | number | boolean | null | undefined): void` - Store a value
- `removeValue(key: string): void` - Remove a value
- `getString(key: string): string | undefined` - Get a string value
- `getNumber(key: string): number | undefined` - Get a number value
- `getBool(key: string): boolean | undefined` - Get a boolean value
- `addToSet(key: string, value: string): void` - Add a value to a set
- `removeFromSet(key: string, value: string): void` - Remove a value from a set
- `setContains(key: string, value: string): boolean` - Check if a set contains a value
**Default Values (Android only):**
The Android implementation initializes certain keys with default values on first access:
- `playSoundChat`: true
- `playSoundFollow`: false
- `playSoundLike`: false
- `playSoundMention`: false
- `playSoundQuote`: false
- `playSoundReply`: false
- `playSoundRepost`: false
- `badgeCount`: 0
**Platform Support:**
- iOS: Full support (uses UserDefaults with App Group)
- Android: Full support (uses SharedPreferences)
- Web: Not implemented
**Implementation Notes:**
- iOS uses App Group suite `group.app.bsky` to share preferences with app extensions
- Android stores preferences in `xyz.blueskyweb.app`
- Both platforms work around a bug where `JavaScriptValue.isString()` can cause crashes, so there's a separate `setString` function internally
### VisibilityView
A React Native view component that detects which view is currently "active" based on visibility and position on screen. Only one view can be active at a time across the entire app.
**Component:**
```tsx
<VisibilityView
enabled={boolean}
onChangeStatus={(isActive: boolean) => void}
>
{children}
</VisibilityView>
```
**Props:**
- `enabled: boolean` - Whether this view participates in visibility tracking
- `onChangeStatus: (isActive: boolean) => void` - Callback fired when the view becomes active or inactive
- `children: React.ReactNode` - Child components
**Functions:**
- `updateActiveViewAsync(): Promise<void>` - Manually trigger recalculation of the active view
**How It Works:**
The module maintains a global registry of all VisibilityView instances. When views are added/removed or when explicitly updated, it calculates which view is "most visible":
1. A view must be at least 50% visible on screen
2. If multiple views meet this threshold, the one closest to the top of the screen wins (specifically, the one with the lowest Y position, but must be at least 150px from the top)
3. Only one view can be active at a time - when a new view becomes active, the previous one is deactivated
This is useful for features like video autoplay, where you want to know which video is currently the "primary" one the user is viewing.
**Platform Support:**
- iOS: Full support using UIView position tracking
- Android: Full support using View position tracking
- Web: Passthrough component (renders children without tracking)
## Architecture
### TypeScript Layer
The module uses platform-specific file extensions to provide appropriate implementations:
- `index.ts` - Throws NotImplementedError (base/fallback)
- `index.native.ts` - Calls native modules via Expo Modules Core
- `index.web.ts` - Web-specific implementations or stubs
- `index.ios.ts` / `index.android.ts` - Platform-specific implementations when behavior differs
### Native Layer
**iOS:**
- Swift implementation using Expo Modules Core
- Files organized by feature in subdirectories (PlatformInfo/, Referrer/, SharedPrefs/, Visibility/)
- Uses standard iOS APIs: UIAccessibility, AVAudioSession, UserDefaults, UIView
**Android:**
- Kotlin implementation using Expo Modules Core
- Package structure: `expo.modules.blueskyswissarmy.[feature]`
- Uses standard Android APIs: Settings.Global, InstallReferrerClient, SharedPreferences, View
## Key Files
### TypeScript
- `index.ts` - Main module exports
- `src/NotImplemented.ts` - Error thrown when functionality is not available on current platform
- `src/[Feature]/types.ts` - TypeScript type definitions for each feature
- `src/[Feature]/index.*.ts` - Platform-specific implementations
### iOS
- `ios/ExpoBlueskySwissArmy.podspec` - CocoaPods specification
- `ios/[Feature]/Expo*Module.swift` - Expo module definitions
- `ios/SharedPrefs/SharedPrefs.swift` - Shared preference manager (usable from other native code)
- `ios/Visibility/VisibilityViewManager.swift` - Global view tracking manager
### Android
- `android/build.gradle` - Gradle build configuration (includes installreferrer dependency)
- `android/src/main/java/expo/modules/blueskyswissarmy/[feature]/Expo*Module.kt` - Expo module definitions
- `android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt` - Shared preference manager
- `android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt` - Global view tracking manager
## Configuration
### Expo Module Config
The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android.
### iOS
Requires iOS 13.4 or later. Uses the App Group `group.app.bsky` for SharedPrefs - ensure this is configured in your app's entitlements.
### Android
- Minimum SDK: 21
- Target SDK: 34
- Requires `com.android.installreferrer:installreferrer:2.2` dependency for Google Play referrer tracking
## Usage Example
```typescript
import {
PlatformInfo,
AudioCategory,
Referrer,
SharedPrefs,
VisibilityView
} from 'expo-bluesky-swiss-army'
// Check for reduced motion
const isReducedMotion = PlatformInfo.getIsReducedMotionEnabled()
// Set audio category for video playback (iOS)
PlatformInfo.setAudioCategory(AudioCategory.Playback)
PlatformInfo.setAudioActive(true)
// Check how user arrived at the app
const referrer = Referrer.getReferrerInfo()
if (referrer) {
console.log('User came from:', referrer.hostname)
}
// Store a preference
SharedPrefs.setValue('lastOpenedAt', Date.now())
SharedPrefs.setValue('hasSeenOnboarding', true)
// Track visible view
<VisibilityView
enabled={true}
onChangeStatus={(isActive) => {
if (isActive) {
// This view is now the primary visible view
video.play()
} else {
video.pause()
}
}}
>
<VideoPlayer />
</VisibilityView>
```
## Version
Current version: 0.6.0
+113 -1
View File
@@ -1,3 +1,115 @@
# expo-emoji-picker
Based on [react-native-emoji-popup](https://github.com/okwasniewski/react-native-emoji-popup) and [expo-emoji-picker](https://github.com/alanjhughes/expo-emoji-picker)
A native emoji picker module for React Native applications built with Expo. This module provides platform-specific emoji selection interfaces using native system components.
Based on [react-native-emoji-popup](https://github.com/okwasniewski/react-native-emoji-popup) and [expo-emoji-picker](https://github.com/alanjhughes/expo-emoji-picker).
## What It Does
The module exposes a React component that presents native emoji picker UI on iOS and Android. When a user selects an emoji, it fires a callback with the selected emoji string.
## Platform Support
- **iOS**: Uses [MCEmojiPicker](https://github.com/izyumkin/MCEmojiPicker) presented as a modal picker
- **Android**: Uses the system `androidx.emoji2.emojipicker.EmojiPickerView` component
- **Web**: Not supported (native platforms only)
## How It Works
### Architecture
The module follows Expo's module architecture with three layers:
1. **JavaScript/TypeScript Layer** (`src/`): React components and type definitions
2. **Native iOS Layer** (`ios/`): Swift implementation using MCEmojiPicker
3. **Native Android Layer** (`android/`): Kotlin implementation using AndroidX emoji picker
### iOS Implementation
On iOS, the module creates an invisible tap target view. When tapped, it presents MCEmojiPicker as a modal view controller:
- `EmojiPickerView.swift`: Custom view that handles tap gestures and presents the picker
- `EmojiPickerModule.swift`: Module definition that registers the view with Expo
- Uses MCEmojiPicker dependency for the native picker UI
The picker is presented from the current React view controller and returns the selected emoji via an event dispatcher.
### Android Implementation
On Android, the module embeds the AndroidX EmojiPickerView directly as a full-screen component:
- `EmojiPickerModuleView.kt`: Wraps the system EmojiPickerView in an ExpoView
- `EmojiPickerModule.kt`: Module definition that registers the view with Expo
- Handles configuration changes (dark mode, orientation) by recreating the view
The AndroidX emoji picker provides a grid-based interface with category tabs and search.
### Platform-Specific React Components
The module uses platform-specific file extensions for different behaviors:
- `EmojiPicker.tsx` (iOS): Renders an invisible tap target that accepts children
- `EmojiPicker.android.tsx` (Android): Renders the full emoji picker view with flex: 1 layout
Both components normalize the native event structure to provide a consistent `onEmojiSelected` callback.
## Key Files
### Configuration
- `expo-module.config.json`: Defines the module name and native class mappings for iOS and Android
### TypeScript/React
- `index.ts`: Public exports for the module
- `src/EmojiPickerModule.ts`: Native module registration
- `src/EmojiPickerModule.types.ts`: TypeScript type definitions
- `src/EmojiPickerView.tsx`: Base native view component
- `src/EmojiPicker.tsx`: iOS-specific implementation
- `src/EmojiPicker.android.tsx`: Android-specific implementation
### iOS (Swift)
- `ios/EmojiPickerModule.swift`: Module definition (11 lines)
- `ios/EmojiPickerView.swift`: View implementation with tap handling and picker presentation
- `ios/EmojiPickerModule.podspec`: CocoaPods specification with MCEmojiPicker dependency
### Android (Kotlin)
- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModule.kt`: Module definition
- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModuleView.kt`: View implementation
- `android/build.gradle`: Gradle configuration with androidx.emoji2:emoji2-emojipicker dependency
## Usage
```tsx
import { EmojiPicker } from 'expo-emoji-picker'
function MyComponent() {
const handleEmojiSelected = (emoji: string) => {
console.log('Selected emoji:', emoji)
}
return (
<EmojiPicker onEmojiSelected={handleEmojiSelected}>
{/* On iOS, children render as the tap target */}
{/* On Android, children are ignored - picker is shown directly */}
</EmojiPicker>
)
}
```
## Dependencies
### iOS
- ExpoModulesCore
- MCEmojiPicker (external CocoaPods dependency)
- Minimum iOS version: 15.1
### Android
- expo-modules-core
- androidx.emoji2:emoji2-emojipicker:1.5.0
- Minimum SDK: 21
- Target SDK: 34
## Configuration
No additional configuration is required. The module is automatically linked through Expo's autolinking system when the app is built.
The module definition in `expo-module.config.json` specifies the native class names for each platform, which Expo uses to register the module at runtime.
+118 -5
View File
@@ -1,8 +1,121 @@
# Expo Receive Android Intents
This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
platforms.
An Expo module that handles incoming Android intents for sharing text, images, and videos into the Bluesky app.
No installation is required. Gradle will automatically add this module on build.
## What It Does
This module intercepts Android share intents (when a user shares content from another app to Bluesky) and converts them into deep links that the app can handle. It supports:
- **Text sharing** - Share plain text to compose a post
- **Image sharing** - Share single or multiple images (up to 4) to attach to a post
- **Video sharing** - Share a single video to attach to a post
The module operates entirely in native Android code and requires no JavaScript API calls. It automatically registers itself with Expo's module system and handles intents when the app is launched or receives new intents.
## Platform Support
- **Android**: Fully supported
- **iOS**: No-op (iOS handles share intents differently)
- **Web**: No-op
## How It Works
### Architecture
The module uses Expo's module lifecycle hooks to intercept Android intents at two key moments:
1. **OnCreate** - When the app is first launched from an intent
2. **OnNewIntent** - When the app receives a new intent while already running
### Intent Processing Flow
1. **Intent Reception**: Android sends an `ACTION_SEND` or `ACTION_SEND_MULTIPLE` intent
2. **Type Detection**: Module determines content type (text, image, or video)
3. **Content Processing**:
- **Text**: URL-encodes the text
- **Images**: Saves to app cache, extracts dimensions (limited to 4 images max)
- **Video**: Copies to app cache with extension detection, extracts dimensions
4. **Deep Link Generation**: Creates a `bluesky://intent/compose` URL with encoded parameters
5. **App Launch**: Starts a new activity with the deep link, which is handled by `useIntentHandler`
### Deep Link Format
The module generates deep links in the following formats:
```
# Text only
bluesky://intent/compose?text=<encoded-text>
# Images (single or multiple)
bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>&text=<encoded-text>
# Video (single only)
bluesky://intent/compose?videoUri=<uri>|<width>|<height>&text=<encoded-text>
```
All URIs use the `file://` scheme pointing to files in the app's cache directory. Dimensions are included to avoid expensive measurement operations in JavaScript.
### Security Considerations
- Images and videos are copied to the app's private cache directory before being passed to the app
- The JavaScript handler (`useIntentHandler.ts`) validates image URIs with a regex to prevent external URLs
- Image URIs containing `http://` or `https://` are filtered out
- Multiple image sharing is limited to 4 images maximum
## Key Files
### Module Configuration
- **expo-module.config.json** - Declares the module and registers it with Expo (Android-only)
### Native Implementation
- **ExpoReceiveAndroidIntentsModule.kt** - Main module class with intent handling logic
- `handleIntent()` - Routes intents based on type
- `handleTextIntent()` - Processes text sharing
- `handleAttachmentIntent()` - Processes single image/video
- `handleAttachmentsIntent()` - Processes multiple images
- `getImageInfo()` - Saves images to cache and extracts dimensions
- `getVideoInfo()` - Extracts video dimensions using MediaMetadataRetriever
- **android/build.gradle** - Gradle build configuration
- Version: 0.4.1
- Requires: Kotlin, expo-modules-core
- Compile SDK: 33, Min SDK: 21, Target SDK: 34
- **android/src/main/AndroidManifest.xml** - Empty manifest (intent filters configured in main app)
### JavaScript Integration
The deep links generated by this module are handled by:
- **src/lib/hooks/useIntentHandler.ts** - `useComposeIntent()` parses the deep link parameters and opens the composer with pre-populated content
## Installation
No manual installation is required. Gradle automatically includes this module during the Android build process. The module is auto-linked through Expo's module system.
## Configuration
Intent filters must be configured in the main app's `AndroidManifest.xml` to declare which MIME types the app accepts. The module itself has an empty manifest.
## Implementation Notes
### Android Version Compatibility
The module uses version-specific APIs for Android 13+ (API 33):
- `getParcelableExtra()` with type parameter on Android 13+
- Legacy `getParcelableExtra()` on older versions
### File Handling
- Temporary files are created using `File.createTempFile()` in the app's cache directory
- Image files use `.jpeg` extension and are compressed at 100% quality
- Video files preserve their original extension, defaulting to `.mp4` if none is detected
### Limitations
- Video sharing only supports a single video
- Multiple video sharing is not implemented
- Images are always converted to JPEG format
- Maximum of 4 images can be shared at once
+116
View File
@@ -0,0 +1,116 @@
# expo-scroll-forwarder
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
## What It Does
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
Key behaviors:
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
- Implements physics-based deceleration animations that match native scroll behavior
- Supports pull-to-refresh interactions with haptic feedback
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
- Provides rubber-band damping when scrolling past content bounds
## Architecture
The module consists of three main parts:
### 1. Native iOS Implementation (Swift)
**ExpoScrollForwarderView.swift** - The core native view component that:
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
- Finds and references the target RCTScrollView using its React Native tag
- Implements custom scroll physics including velocity-based decay animation
- Manages gesture recognizer delegation to prevent conflicts with system gestures
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
- Registers the view component with Expo
- Exposes the `scrollViewTag` prop to specify which scroll view to control
### 2. TypeScript Interface
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
- `scrollViewTag`: The React Native tag of the scroll view to control
- `children`: The content to render (typically a header component)
### 3. Module Configuration
**expo-module.config.json** - Declares iOS-only platform support
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
## Usage
```tsx
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
function ProfileScreen() {
const scrollViewTag = useRef(null)
return (
<View>
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
<ProfileHeader />
</ExpoScrollForwarderView>
<ScrollView ref={scrollViewTag}>
{/* Scrollable content */}
</ScrollView>
</View>
)
}
```
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
## Platform Support
- **iOS**: Full native implementation with custom scroll physics
- **Android**: No-op wrapper (renders children without scroll forwarding)
- **Web**: No-op wrapper (renders children without scroll forwarding)
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
## Key Implementation Details
### Gesture Recognition
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
### Scroll Physics
- Implements custom decay animation at 120fps using a Timer
- Velocity decay factor: 0.9875 per frame
- Velocity clamped to +/- 5000 points/second
- Rubber-band damping: offsets below 0 are reduced by 55%
- Animation stops when velocity drops below 5 points/second
### Pull-to-Refresh
- Triggers at -130pt scroll offset
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
### Scroll View Management
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
- Properly cleans up gesture recognizers when switching between scroll views
- Maintains references to both the scroll view and its refresh control
## Files Overview
| File | Purpose |
|------|---------|
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
| `index.ts` | Module entry point |
| `expo-module.config.json` | Expo module configuration |
+78 -92
View File
@@ -1,11 +1,22 @@
{
"name": "bsky.app",
"version": "1.121.0",
"version": "1.122.0",
"private": true,
"engines": {
"node": ">=20"
"node": ">=24.15.0"
},
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.1.3",
"onFail": "warn"
},
"runtime": {
"name": "node",
"version": "^24.15.0",
"onFail": "download"
}
},
"packageManager": "yarn@1.22.22",
"expo": {
"autolinking": {
"android": {
@@ -26,8 +37,8 @@
}
},
"scripts": {
"prepare": "is-ci || husky install",
"postinstall": "patch-package && yarn intl:compile-if-needed",
"prepare": "is-ci || husky",
"postinstall": "pnpm intl:compile-if-needed",
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
"android": "expo run:android",
"android:prod": "expo run:android --variant release",
@@ -37,11 +48,11 @@
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
"build-web": "expo export:web && node ./scripts/post-web-build.js",
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
"build": "yarn use-build-number-with-bump eas build",
"build-embed": "cd bskyembed && yarn build && yarn build-snippet && cd .. && node ./scripts/post-embed-build.js",
"build-all": "pnpm intl:build && pnpm use-build-number-with-bump eas build --platform all",
"build-ios": "pnpm use-build-number-with-bump eas build -p ios",
"build-android": "pnpm use-build-number-with-bump eas build -p android",
"build": "pnpm use-build-number-with-bump eas build",
"build-embed": "cd bskyembed && pnpm build && pnpm build-snippet && cd .. && node ./scripts/post-embed-build.js",
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
@@ -51,7 +62,7 @@
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"typecheck": "tsgo --project ./tsconfig.check.json",
"e2e:mock-server": "cd dev-env && yarn start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
@@ -59,38 +70,41 @@
"e2e:run": "maestro test",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"pnpm perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"intl:build": "yarn intl:extract:all && yarn intl:compile",
"intl:build": "pnpm intl:extract:all && pnpm intl:compile",
"intl:extract": "lingui extract --clean --locale en",
"intl:extract:all": "lingui extract --clean",
"intl:compile": "lingui compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.ts ] || yarn intl:compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.ts ] || pnpm intl:compile",
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
"intl:release": "yarn intl:pull && yarn intl:extract:all",
"intl:release": "pnpm intl:pull && pnpm intl:extract:all",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
"upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist",
"export": "expo export --dump-sourcemap && pnpm upload-native-sourcemaps",
"upload-native-sourcemaps": "pnpm exec sentry-expo-upload-sourcemaps dist",
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web",
"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"
},
"dependencies": {
"@atproto/api": "^0.19.9",
"@atproto/api": "0.20.4",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/alf": "^0.1.9",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.3",
"@bsky.app/tapper": "^0.5.1",
"@bsky.app/sift": "^0.3.4",
"@bsky.app/tapper": "^0.5.3",
"@bsky.app/video": "0.3.4",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -108,8 +122,7 @@
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@miblanchard/react-native-slider": "^2.6.0",
@@ -136,7 +149,7 @@
"@tiptap/suggestion": "^2.9.1",
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^20.14.3",
"@types/node": "^24.12.2",
"array.prototype.findlast": "^1.2.3",
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
@@ -146,7 +159,7 @@
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.33",
"expo": "54.0.34",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
@@ -169,15 +182,15 @@
"expo-localization": "~17.0.8",
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.16",
"expo-paste-input": "^0.1.15",
"expo-notifications": "~0.32.17",
"expo-paste-input": "^0.2.1",
"expo-privacy-sensitive": "^0.1.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.13",
"expo-system-ui": "~6.0.9",
"expo-updates": "~29.0.16",
"expo-updates": "~29.0.17",
"expo-video": "~3.0.16",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
@@ -194,52 +207,51 @@
"lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"multiformats": "9.9.0",
"multiformats": "^13.4.2",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
"patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"psl": "1.9.0",
"radix-ui": "^1.4.3",
"react": "19.1.0",
"react-compiler-runtime": "^19.1.0-rc.1",
"react-compiler-runtime": "19.1.0-rc.3",
"react-dom": "19.1.0",
"react-hotkeys-hook": "5.2.4",
"react-image-crop": "^11.0.7",
"react-is": "19",
"react-keyed-flatten-children": "^5.0.0",
"react-native": "0.81.5",
"react-native-compressor": "^1.13.0",
"react-native-compressor": "1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.2",
"react-native-drawer-layout": "^4.2.3",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.21.5",
"react-native-keyboard-controller": "^1.21.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
"react-native-reanimated": "3.19.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "^4.24.0",
"react-native-screens": "4.24.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "^0.21.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.13.5",
"react-native-webview": "^13.15.0",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
"setimmediate": "^1.0.5",
"slugify": "^1.6.9",
"sonner": "^2.0.7",
"sonner-native": "^0.21.0",
"sonner-native": "0.21.0",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"tldts": "^6.1.46",
"unicode-segmenter": "^0.14.5",
"zod": "^3.20.2"
"unicode-segmenter": "0.14.5",
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/core": "^7.26.0",
@@ -258,34 +270,34 @@
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/psl": "1.1.1",
"@types/react": "^19.1.17",
"@types/react-dom": "^19.1.11",
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"babel-preset-expo": "~54.0.10",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-bsky-internal": "link:eslint",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-lingui": "^0.12.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^13.0.0",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
"husky": "^9.1.7",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~54.0.17",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"prettier": "^3.6.0",
"prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
@@ -294,19 +306,6 @@
"typescript-eslint": "^8.58.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.12",
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6",
"metro": "0.83.3",
"metro-core": "0.83.3",
"metro-config": "0.83.3",
"metro-runtime": "0.83.3",
"metro-source-map": "0.83.3"
},
"jest": {
"preset": "jest-expo/ios",
"setupFilesAfterEnv": [
@@ -323,8 +322,19 @@
"transform": {
"\\.[jt]sx?$": "babel-jest"
},
"moduleNameMapper": {
"^multiformats$": "<rootDir>/node_modules/multiformats/dist/src/index.js",
"^multiformats/cid$": "<rootDir>/node_modules/multiformats/dist/src/cid.js",
"^multiformats/bases/base32$": "<rootDir>/node_modules/multiformats/dist/src/bases/base32.js",
"^multiformats/hashes/digest$": "<rootDir>/node_modules/multiformats/dist/src/hashes/digest.js",
"^multiformats/hashes/sha2$": "<rootDir>/node_modules/multiformats/dist/src/hashes/sha2.js",
"^uint8arrays/from-string$": "<rootDir>/node_modules/uint8arrays/dist/src/from-string.js",
"^uint8arrays/to-string$": "<rootDir>/node_modules/uint8arrays/dist/src/to-string.js",
"^unicode-segmenter/grapheme$": "<rootDir>/node_modules/unicode-segmenter/grapheme.cjs",
"^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)"
"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)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
@@ -366,29 +376,5 @@
"assets/icons/*.svg": [
"svgo"
]
},
"lockfile-lint": {
"path": "yarn.lock",
"allowedHosts": [
"npm",
"yarn"
],
"allowedSchemes": [
"https:"
],
"allowedPackageNameAliases": [
"@babel/traverse--for-generate-function-map:@babel/traverse",
"string-width-cjs:string-width",
"strip-ansi-cjs:strip-ansi",
"wrap-ansi-cjs:wrap-ansi"
],
"allowedUrls": [
"https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908",
"https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4",
"https://codeload.github.com/mattermost/react-native-paste-input/tar.gz/f260447edc645a817ab1ba7b46d8341d84dba8e9"
],
"emptyHostname": false,
"validatePackageNames": true,
"validateIntegrity": true
}
}
@@ -1,7 +1,7 @@
diff --git a/node_modules/@discord/bottom-sheet/src/hooks/useStableCallback.ts b/node_modules/@discord/bottom-sheet/src/hooks/useStableCallback.ts
index 1c788ab..d30f330 100644
--- a/node_modules/@discord/bottom-sheet/src/hooks/useStableCallback.ts
+++ b/node_modules/@discord/bottom-sheet/src/hooks/useStableCallback.ts
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
*/
-136
View File
@@ -1,136 +0,0 @@
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
index b988d3f..7743421 100644
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
@@ -36,6 +36,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ consumerProguardFiles 'proguard-rules.pro'
}
lintOptions {
abortOnError false
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
new file mode 100644
index 0000000..3b5b864
--- /dev/null
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
@@ -1,7 +1,7 @@
diff --git a/node_modules/@sentry/react-native/dist/js/tools/sentryMetroSerializer.js b/node_modules/@sentry/react-native/dist/js/tools/sentryMetroSerializer.js
index d7f2350..e9c0368 100644
--- a/node_modules/@sentry/react-native/dist/js/tools/sentryMetroSerializer.js
+++ b/node_modules/@sentry/react-native/dist/js/tools/sentryMetroSerializer.js
diff --git a/dist/js/tools/sentryMetroSerializer.js b/dist/js/tools/sentryMetroSerializer.js
index d7f2350196fc008f9f1ce530e224fe3d052347e9..4ce7b6614e38c8af747ebcf166dba556affea3de 100644
--- a/dist/js/tools/sentryMetroSerializer.js
+++ b/dist/js/tools/sentryMetroSerializer.js
@@ -12,12 +12,9 @@ exports.createSentryMetroSerializer = exports.unstable_beforeAssetSerializationP
const crypto = require("crypto");
const utils_1 = require("./utils");
@@ -18,10 +18,10 @@ index d7f2350..e9c0368 100644
}
const DEBUG_ID_PLACE_HOLDER = '__debug_id_place_holder__';
const DEBUG_ID_MODULE_PATH = '__debugid__';
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
index b3783b5..d5e3e45 100755
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
diff --git a/scripts/expo-upload-sourcemaps.js b/scripts/expo-upload-sourcemaps.js
index b3783b572171482778d31a96b8d6ebadbcc8783b..d5e3e45477c07b5419285237372d99ddd83c56a6 100755
--- a/scripts/expo-upload-sourcemaps.js
+++ b/scripts/expo-upload-sourcemaps.js
@@ -218,7 +218,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
const isHermes = assets.find(asset => asset.endsWith('.hbc'));

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