Compare commits

...

256 Commits

Author SHA1 Message Date
Eric Bailey 768844b261 Fix label 2026-05-22 17:27:29 -05:00
Eric Bailey f3de5ccdda Fix up mobile 2026-05-22 17:24:45 -05:00
Eric Bailey 6ffe4d2456 Bump api pkg 2026-05-22 17:14:35 -05:00
Eric Bailey c4c679446c [APP-2160] Use publisher icon for known hosts in PublicationMetaRow
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:14:04 -05:00
Eric Bailey f79453d87d Fix up loose docs with PubMetaRow 2026-05-22 17:14:04 -05:00
Eric Bailey 335bd5fe7d Update comment 2026-05-22 17:13:55 -05:00
Eric Bailey 8cc535513f [APP-2160] Render alf Context directly in StandardSiteThemeProvider
Nesting a full ThemeProvider spun up its own font-scale state, freezing
the SubscribeButton subtree from runtime parent updates. Reuse the
parent alf value and only swap the themes map.
2026-05-22 17:13:55 -05:00
Eric Bailey 827bb42d60 [APP-2160] Centralize known publisher lookup with strict host matching
Move the publisher list and host detection into a single publishers.ts
module so both PublicationMetaRow and the Subscribe button consult the
same source. Match exact hosts and proper subdomains only, rejecting
lookalikes like evilleaflet.pub.
2026-05-22 17:13:55 -05:00
Eric Bailey deb31b2eb9 [APP-2160] Stabilize avatar style and drop stray Fragments
Hoist the publication avatar style to a module-level const so its
identity is stable across renders, and remove two empty Fragment
wrappers that served no purpose.
2026-05-22 17:13:55 -05:00
Eric Bailey 19d5431c63 [APP-2160] Validate hexToRgb input and guard against throws
Reject malformed hex strings instead of producing garbled output, drop
the alpha channel from 8-digit hex, and wrap the public entrypoints in
try/catch so callers always get a usable color string back.
2026-05-22 17:13:55 -05:00
Eric Bailey ee8d866b72 [APP-2160] Drop manual memoization in StandardSiteEmbed
React Compiler handles this; the explicit useMemo on themeColors was
also missing t in its deps, causing stale colors on theme toggle.
2026-05-22 17:13:55 -05:00
Eric Bailey 281ce95850 Rm not-null assert 2026-05-22 17:13:55 -05:00
Eric Bailey 785fc26ec4 [APP-2160] Extract StandardSiteEmbed type guards into utils.ts
Move isStandardSiteEmbed alongside a new isStandardSitePublicationEmbed
helper. The publication check now requires at least one publication ref
and zero document refs, replacing a tautological condition that also
returned true for empty associatedRefs arrays.
2026-05-22 17:13:55 -05:00
Eric Bailey 726c5b9a2d Improve byline linking and interaction states 2026-05-22 17:13:55 -05:00
Eric Bailey 4fc548eb17 Split out components, link byline 2026-05-22 17:13:55 -05:00
Eric Bailey 11636ed58f Update theming 2026-05-22 17:13:55 -05:00
Eric Bailey 988c6d324f Fix types 2026-05-22 17:13:55 -05:00
Eric Bailey 21c9e5c38a UI work 2026-05-22 17:13:54 -05:00
vineyardbovines 1adfdb4a15 [APP-2160] Address /grill review: move logger.error to useEffect; guard readingTime > 0
- PublicationFooter: profile-resolve error was logged during render, causing
  duplicate Sentry events on every re-render of an errored card. Move to
  useEffect keyed on the error transition.
- MetaRow: render reading-time chip only when readingTime > 0. Guards against
  pathological appview responses where the field is 0 or negative.
2026-05-22 17:13:41 -05:00
vineyardbovines c418f7a3df [APP-2160] Flush divider; move footer vertical padding to py_md
Previously the divider's own py_md wrapper plus the footer's py_sm
double-counted the spacing and pushed the footer further from the
divider than Figma. Make the divider flush against the article block
and the footer, and bump the footer to py_md so the 12px gap below
the divider line is owned by the footer itself.
2026-05-22 17:13:40 -05:00
vineyardbovines b636597b38 [APP-2160] Apply Figma typography + spacing tweaks to PublicationEmbed
PublicationFooter:
- Avatar to text gap: 10
- Title to author gap: 2
- Title weight medium (500) instead of semi-bold (600)

PublicationEmbed content block:
- Description to meta-row gap: 6 (was 8)
- Divider gets 12px vertical padding wrapper
2026-05-22 17:13:40 -05:00
vineyardbovines 3df3b7bf49 [APP-2160] Fix PublicationEmbed hero image width on web
The inner View inside the article-region Link was missing `a.w_full`, so on
web (where Link renders as an inline <a>) it shrink-wrapped to the Image's
intrinsic width. Images with small intrinsic dimensions (Leaflet thumbnails)
rendered tiny, while large ones (PCKT) rendered correctly by accident.

The legacy ExternalEmbed sets `a.w_full` on the equivalent View directly
inside the Link wrapper; matching that pattern here.
2026-05-22 17:13:40 -05:00
vineyardbovines 51c695d6f6 [APP-2160] Dispatch PublicationEmbed from composer link preview 2026-05-22 17:13:40 -05:00
vineyardbovines e3ce0a4c33 [APP-2160] Dispatch PublicationEmbed from MediaEmbed when source present 2026-05-22 17:13:40 -05:00
vineyardbovines 3d11904294 [APP-2160] Add PublicationEmbed card component 2026-05-22 17:13:40 -05:00
vineyardbovines 9ef53e3912 [APP-2160] Add PublicationFooter for PublicationEmbed 2026-05-22 17:13:40 -05:00
vineyardbovines 00ae488e7d [APP-2160] Add MetaRow with date + reading-time chips for PublicationEmbed 2026-05-22 17:13:40 -05:00
vineyardbovines ffa58f6cb2 [APP-2160] Add local PublicationViewExternal types + extend ResolvedExternalLink 2026-05-22 17:13:40 -05:00
vineyardbovines feb623520c [APP-2160] Add parseDidFromAtUri util for PublicationEmbed 2026-05-22 17:13:40 -05:00
DS Boyce 6159cd7777 Add loading state to message composer and input (#10595) 2026-05-22 13:57:19 -07:00
DS Boyce 0d14513939 Update appearance of blocked chat footer (#10597) 2026-05-22 13:56:51 -07:00
Samuel Newman 66d37f55b4 Fix autofocusing Emoji reaction picker search (#10599) 2026-05-22 23:07:47 +03:00
DS Boyce 96ae32b78e Fix chat message spacing and alignment (#10579) 2026-05-22 11:08:03 -07:00
DS Boyce 4bdedc55be Add Info Panel to direct chats (#10576) 2026-05-22 10:34:21 -07:00
DS Boyce f2e7028709 Update appearance of chat when blocked (#10582) 2026-05-22 09:46:57 -07:00
Samuel Newman 2a546bfd52 [Chat] Disable convo avatar in NSE (#10594) 2026-05-22 09:30:20 -07:00
Samuel Newman c21c031e74 [pnpm] Attempt to remove eas-cli from devDeps (#10589) 2026-05-22 09:17:12 -07:00
Samuel Newman 42d993a78f [Chat] Fix iOS group chat notifications rendering as direct messages (#10590)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:51:43 -07:00
DS Boyce 7c5a9ab40d Fix issue where suggested language prompt could not be dismissed (#10581) 2026-05-22 01:54:54 -07: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
Samuel Newman 591504307d [Chat] Minor bugfixes (#10284) 2026-04-17 10:50:52 -07:00
DS Boyce bc9ad2c2d9 Fix crash when deleting account from NoAccessScreen (#10283) 2026-04-17 10:48:08 -07:00
DS Boyce 9e9ff70682 Enable access for group clip clops with no messages (#10276) 2026-04-17 10:40:37 -07:00
Samuel Newman 226a321a27 Fix GrowthBook cache breaking on cold start (#10282)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:12:12 -07:00
Samuel Newman e58feaeb0f Animate border radius in lightbox transition (#10272) 2026-04-17 05:45:56 -07:00
Spence Pope a77b6e3525 [APP-2066] Migrate from Tenor to KLIPY (#10240)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:43:13 -04:00
Samuel Newman a97b15b204 EmojiPicker component (#10249) 2026-04-17 02:42:10 -07:00
pfrazee 8f56fca82c Nightly source-language update 2026-04-17 03:14:49 +00:00
DS Boyce 524cbc514d Don't toggle the date divider when tapping a reaction (#10274) 2026-04-16 13:06:09 -07:00
Samuel Newman 3358e1947b Lazily measure lightbox thumbnails (#10270) 2026-04-16 12:24:11 -07:00
DS Boyce 6e3c9c3a9f Add flow for adding people to a group clip clop (#10255) 2026-04-16 11:55:19 -07:00
DS Boyce ac68cfe98c Remove own reaction from reactions dialog on tap (#10231)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-16 11:16:43 -07:00
DS Boyce 36c95d7dc6 Remove console log (#10273) 2026-04-16 10:57:48 -07:00
DS Boyce 9f3c21e298 Add header to group clip clops (#10254)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-16 10:48:30 -07:00
DS Boyce e10c05d735 Reveal chat timestamp on tap (#10262) 2026-04-16 10:17:01 -07:00
DS Boyce a9e170b6d0 Integrate more group chat settings (#10253) 2026-04-16 10:15:59 -07:00
Samuel Newman f51602b3fe Support group chats across other surfaces (#10266) 2026-04-16 09:55:30 -07:00
Eric Bailey cc8f22887f [APP-2031] DM composer fast-follows (#10269) 2026-04-16 11:37:03 -05:00
Samuel Newman cc861093c2 Make padding equal in the bottom bar (#10265) 2026-04-16 08:31:38 -07:00
Eric Bailey 8c5899fc93 Add warning log to gates init (#10268) 2026-04-16 10:06:47 -05:00
pfrazee 6d8b4a2070 Nightly source-language update 2026-04-16 03:15:06 +00:00
Eric Bailey 5fb3af71c6 Add gate debug code (#10264) 2026-04-15 20:43:01 -05:00
Spence Pope e804546809 [APP-1934] replace image grid layout with carousel (#10157)
Co-authored-by: RetroSunstar <57507616+RetroSunstar@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-15 17:53:01 -05:00
Samuel Newman d3f5093817 Groupchats feature branch (#10181)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:15:45 -07:00
DS Boyce 75c9e2c181 Upgrade eslint-plugin-simple-import-sort to latest (#10260) 2026-04-15 11:00:34 -07:00
DS Boyce b9561f78ee Enforce using named imports from 'react' (#10259) 2026-04-15 10:11:09 -07:00
Samuel Newman 19e2dc939a [bskyembed] Fix landing page for iOS 26 safari dark mode (#10257) 2026-04-15 10:05:22 -07:00
Samuel Newman ecc78efb12 Stop passing _ around in composer functions (#10256) 2026-04-15 07:39:51 -07:00
pfrazee bcbc114189 Nightly source-language update 2026-04-15 03:14:26 +00:00
Spence Pope 8d3eba2381 [ALG-77] include 'for you' in see more suggested users (#10241) 2026-04-14 17:50:00 -05:00
Eric Bailey 59a2d19c26 Fix stale searchText in search submit handler (#10251)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:42:43 -05:00
pfrazee eee2df6d3a Nightly source-language update 2026-04-14 03:15:02 +00:00
Samuel Newman c56427c6fb Fix inverted condition in moveIfNecessary (#10238)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:42:57 -07:00
Samuel Newman 9c502b38e5 Disable viewport zoom in prod (#10233) 2026-04-13 09:42:37 -07:00
Samuel Newman 1f6d6d0545 Fix embed options border radius (#10237) 2026-04-13 09:42:05 -07:00
Samuel Newman 27d1b96e73 Fix bskyembed skeleton padding (#10236)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:41:14 -07:00
584 changed files with 141235 additions and 73691 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
+56 -7
View File
@@ -8,7 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
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'
@@ -450,6 +450,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
]
const outputs = [
@@ -845,6 +852,35 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1035,17 +1071,30 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
describe('tenorUrlToBskyGifUrl', () => {
describe('klipyUrlToBskyGifUrl', () => {
const inputs = [
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
]
it.each(inputs)(
'returns url with t.gifs.bsky.app as hostname for input url',
'returns url with k.gifs.bsky.app as hostname for input url',
input => {
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
const out = klipyUrlToBskyGifUrl(input)
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
},
)
it('preserves the path and query params when rewriting', () => {
const out = klipyUrlToBskyGifUrl(
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
expect(out).toEqual(
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
})
it('returns empty string for invalid URLs', () => {
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
})
})
+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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M16.091 3.001c.385-.01 1.07.04 1.51.646q.122.17.186.334c.317-.029.759-.066 1.17-.032.395.034 1.195.163 1.661.9.411.65.308 1.316.107 1.768a3.3 3.3 0 0 1-.416.667c.142.124.302.293.428.507.302.514.316 1.083.182 1.62-.236.944-1.025 1.356-1.504 1.54-.493.19-1.07.294-1.502.371l-.05.009-.1.017c.106.368.084.759-.039 1.13-.34 1.025-1.114 1.513-1.85 1.718-.632.176-1.337.176-1.839.175h-.18c.126.508-.018 1.009-.158 1.348-.52 1.25-1.696 1.717-2.647 1.915s-2.052.204-2.966.21h-.063c-.5.002-.945.005-1.334.027-.345.666-.63 1.407-.962 2.271v.003l-.27.701c-.37.943-1.413 1.4-2.33 1.02-.918-.378-1.364-1.45-.996-2.392.473-1.207.962-2.049 1.615-3.342.175-.348.322-.628.452-.889 0-.786.057-1.341.17-2.045.19-1.183.35-1.639.585-2.718.215-.746.532-1.747 1.03-2.299.285-.317.727-.78 1.392-.812.404-.02.656.315.869.457.618-.86 1.143-2.005 1.82-2.894.542-.713 1.125-1.336 2.067-1.188.485.077 1.176.452 1.176 1.188.18-.205.729-.995.96-1.188.44-.367 1.048-.721 1.826-.743m-.347.953c-.828.023-1.412.747-1.902 1.354-.355.44-.66.82-.975.828-.308.008-.285-.204-.256-.473.042-.383.095-.88-.78-1.02-.726-.114-1.492 1.266-2.157 2.464-.455.82-.864 1.555-1.18 1.667-.482.172-.705-.03-.896-.202-.23-.208-.413-.373-.947.22-.563.623-.947 2.328-1.319 4.186-.047.237.283.39.434.203.856-1.057 2.248-2.51 3.908-3.585 1.526-.988 2.998-1.97 4.664-2.464.245-.073.315.21.08.312-1.878.82-3.491 1.903-5.181 3.348-1.9 1.589-3.696 3.703-4.607 5.427a22 22 0 0 0-1.511 3.127.6.6 0 0 0 .32.771.573.573 0 0 0 .751-.329q.134-.345.262-.68c.39-1.02.756-1.976 1.271-2.845a.21.21 0 0 1 .154-.101c.56-.069 1.227-.073 1.917-.077 1.881-.01 3.936-.023 4.487-1.348.287-.692-.06-.792-.373-.881-.255-.074-.489-.141-.335-.52.268-.655 1.138-.655 2.061-.655 1.075 0 2.22 0 2.563-1.036.147-.443-.115-.618-.347-.772-.194-.129-.366-.244-.262-.492.167-.394.89-.525 1.658-.663.917-.165 1.895-.34 2.057-.99.154-.614-.133-.812-.429-1.017-.18-.126-.364-.255-.454-.481-.11-.279.13-.576.38-.885.296-.365.604-.746.362-1.13-.276-.435-1.096-.358-1.775-.295-.424.04-.793.073-.94-.025-.148-.1-.142-.268-.136-.436.009-.262.017-.521-.566-.505"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7.638 11.873c.096.796.45 2.419 1.255 3.787.097.191.354.16.419 0l4.636-8.401a.394.394 0 0 0-.097-.51 3.73 3.73 0 0 0-2.575-.89c-3.703.095-3.735 4.71-3.638 6.014m5.086 6.237c3.606-.095 3.735-4.677 3.606-5.95-.064-.828-.418-2.483-1.255-3.915-.065-.127-.29-.127-.355 0l-4.668 8.465c-.064.191-.032.382.097.51a3.73 3.73 0 0 0 2.575.89m3.928-13.365-.193.35c-.129.223-.065.477.129.573C19.163 7.29 20 10.09 20 11.937c0 2.291-1.931 7.637-7.823 7.637-1.159 0-2.157-.159-3.058-.445a.415.415 0 0 0-.483.159l-.258.509c-.096.19-.322.255-.515.16l-.354-.192a.37.37 0 0 1-.16-.509l.224-.414c.097-.19.033-.413-.128-.509C4.869 16.678 4 13.846 4 12.064c0-2.291 1.77-7.637 7.694-7.637 1.224 0 2.254.159 3.155.445a.46.46 0 0 0 .515-.19l.29-.51a.366.366 0 0 1 .483-.127l.386.19c.161.096.226.319.129.51"/></svg>

After

Width:  |  Height:  |  Size: 881 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M4.686 8.752c1.117 0 1.88.517 2.246 1.52q.247.678.249 1.643l-.001.097q-.014.94-.267 1.6l-.074.176c-.394.858-1.123 1.303-2.153 1.303a2.32 2.32 0 0 1-1.408-.455l-.035-.028v1.966a1 1 0 0 1-.008.074c-.029.178-.146.267-.359.267H.854q-.273 0-.335-.216l-.01-.035a1 1 0 0 1-.009-.09v-7.34a.7.7 0 0 1 .02-.149.27.27 0 0 1 .155-.18l.027-.01.005-.002a.5.5 0 0 1 .126-.02h2.066q.04 0 .076.007h.007l.048.013a.27.27 0 0 1 .204.231l.005.028a1 1 0 0 1 .004.07 2 2 0 0 1 .185-.131l.039-.024a2 2 0 0 1 .289-.144q.413-.17.93-.171m5.367.041c.46 0 .958.048 1.655.289l.015.005.021.008.06.021.02.007.018.008.017.007.033.014.024.012.015.008.02.01.003.004.01.005.006.004.01.007.003.003.01.008.005.003.009.008.003.003.009.007.011.012.008.009.004.004.006.008.005.005.005.007.013.023.005.009.004.007.002.003a.4.4 0 0 1 .036.134l.004.05.083 1.389a1 1 0 0 1 0 .083v.01l-.004.03-.007.043-.002.008q0 .006-.004.012v.005l-.005.014-.005.014-.002.003-.005.011-.002.004-.006.01-.002.004-.005.008q0 .003-.003.005l-.003.003-.006.009-.003.004-.018.016-.023.018-.005.003-.01.005h-.002l-.01.006q-.004 0-.008.003l-.007.002-.007.002-.009.003-.007.001-.01.003-.005.001-.013.002H12l-.012.002h-.007l-.013.001h-.048l-.013-.003-.057-.008-.124-.02a9 9 0 0 0-1.272-.112c-.804 0-1.146.265-1.146.857 0 .495.212.857 1.134.857a10 10 0 0 0 1.396-.12c.271-.049.366.06.354.326l-.083 1.388a1 1 0 0 1-.01.093l-.001.008-.003.013-.002.006-.004.012-.018.047-.007.016-.002.002-.006.011-.003.006c-.068.107-.192.153-.358.21a4.5 4.5 0 0 1-1.655.3c-1.24 0-2.135-.377-2.706-.996.306-.61.445-1.365.445-2.219 0-.828-.13-1.562-.418-2.16.574-.6 1.464-.963 2.69-.963m11.419-1.227q.354 0 .354.363v1.073c.386.048.796.116 1.265.21.2.041.327.242.285.446l-.307 1.477a.374.374 0 0 1-.438.293 17 17 0 0 0-.805-.142v.603c0 .724.425.82.71.821.117 0 .33-.024.52-.048.271-.036.39.072.378.338l-.082 1.46c-.012.29-.166.46-.415.52a5.5 5.5 0 0 1-1.24.145c-1.869 0-2.614-.978-2.614-2.982v-.985l-.165.008a.4.4 0 0 1-.262-.085l-.002-.002a.4.4 0 0 1-.04-.039l-.004-.003a.4.4 0 0 1-.05-.071l-.003-.005a.4.4 0 0 1-.023-.052l-.002-.005-.008-.025v-.006a.4.4 0 0 1-.01-.057l-.001-.011v-.006l-.001-.019v-.784c0-.165.07-.322.193-.43.191-.168.493-.435.759-.673.154-.139.296-.268.397-.363l.003-.003.744-.683c.201-.181.438-.278.71-.278zM14.052 7c.249 0 .368.12.368.362v2.52c.375-.199.75-.37 1.143-.512.91-.328 1.852-.47 3.017-.482l-.283.25a1.14 1.14 0 0 0-.385.858v.784q.002.214.083.394c-.294.02-.56.052-.806.096l.01.029c.436 1.35 1.099 3.357 1.099 3.357.082.254-.013.386-.273.386h-2.01a.524.524 0 0 1-.532-.386l-.77-2.338q-.142.09-.293.188v2.174q.002.362-.367.362h-1.665a.95.95 0 0 0 .23-.6l.083-1.39v-.007c.008-.165-.001-.484-.243-.718-.248-.24-.564-.225-.748-.194l-.033.005v-.355l.033.006c.197.035.521.046.77-.208.23-.234.24-.544.233-.713v-.009l-.083-1.388a.93.93 0 0 0-.317-.685 1.3 1.3 0 0 0-.382-.209l-.093-.032-.161-.054V7.362q0-.362.354-.362zM3.919 10.868c-.556 0-.74.423-.74 1.132s.184 1.132.74 1.132.738-.423.738-1.132-.182-1.132-.738-1.132"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.781 4c1.543 0 2.853.528 3.752 1.661q.132.167.25.347.537.358.963.891C18.62 8 19 9.54 19 11.355c0 1.444-.241 2.715-.777 3.73q.081.057.156.128c.238.224.376.504.463.752l.006.014.004.015c.112.359.188.88-.106 1.362q-.091.15-.205.26.024.154.013.322c-.042.569-.407.95-.721 1.18-.234.169-.568.355-.986.355a1.3 1.3 0 0 1-.627-.157 1.3 1.3 0 0 1-.63.157c-.4 0-.744-.186-.978-.356l-.01-.008c-.258-.193-.571-.503-.68-.952a2 2 0 0 1-.523-.278l-.01-.008c-.16-.12-.34-.284-.48-.497a5 5 0 0 1-.373-.1v1.687c0 .341-.085.773-.423 1.11-.333.331-.756.412-1.089.412H7.705c-.334 0-.752-.086-1.08-.42a1.46 1.46 0 0 1-.394-.836 1.4 1.4 0 0 1-.819-.402C5.084 18.491 5 18.064 5 17.723V5.72c0-.341.084-.767.412-1.102.328-.334.746-.42 1.08-.42h3.32c.27 0 .596.053.886.245A4.85 4.85 0 0 1 12.781 4m0 .929c-.95 0-1.747.277-2.368.772 0-.376-.194-.574-.601-.574h-3.32q-.582 0-.583.594v12.002q0 .594.583.594h3.32c.407 0 .601-.198.601-.594v-3.189c.63.487 1.41.782 2.321.792.005-.147.037-.294.083-.43.068-.215.17-.425.336-.585a.91.91 0 0 1 .742-.248 1 1 0 0 1 .209-.425c.236-.277.582-.354.906-.354.32 0 .667.078.902.356q.049.059.086.122c.579-.868.879-2.089.879-3.644 0-3.406-1.437-5.19-4.096-5.19m2.229 8.819c-.532 0-.744.259-.68.784l.037.194-.175-.092a.9.9 0 0 0-.394-.111c-.266 0-.44.167-.55.526-.166.488.018.774.541.866l.192.027-.137.158c-.376.368-.348.7.083 1.023.165.12.32.183.449.184.211 0 .395-.147.542-.433l.092-.176.092.176c.146.286.32.433.532.433.146 0 .294-.064.459-.184.431-.313.449-.655.082-1.033l-.128-.148.183-.027c.533-.092.697-.378.541-.876-.119-.34-.293-.507-.54-.507a.96.96 0 0 0-.405.11l-.183.084.027-.194q.124-.786-.66-.784m-3.49-5.349c.913 0 1.213.696 1.213 1.858s-.3 1.856-1.213 1.856c-.912 0-1.212-.694-1.212-1.856s.3-1.857 1.213-1.858"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.846 2.047a.275.275 0 0 1 .308 0c.108.073.166.791.283 2.227l.04.499h2.76a3.955 3.955 0 0 1 3.954 3.954v.682h-1.364v-.682a2.59 2.59 0 0 0-2.59-2.59H12.59c.123 1.38.245 2.183.605 2.84a4.37 4.37 0 0 0 1.688 1.708c.808.455 1.845.556 3.918.759l1.14.11c1.294.127 1.942.19 2.014.298a.27.27 0 0 1-.003.304c-.073.107-.722.16-2.018.266l-.743.06v2.79a3.954 3.954 0 0 1-3.955 3.955h-2.762l-.037.467c-.116 1.457-.174 2.185-.283 2.259a.275.275 0 0 1-.308 0c-.108-.073-.166-.802-.282-2.259l-.038-.467H8.691a3.955 3.955 0 0 1-3.955-3.954v-.682H6.1v.682a2.59 2.59 0 0 0 2.59 2.59h2.725c-.124-1.433-.247-2.26-.618-2.932a4.37 4.37 0 0 0-1.72-1.715c-.823-.452-1.878-.538-3.988-.71l-1.023-.084c-1.296-.106-1.945-.16-2.018-.266a.27.27 0 0 1-.003-.304c.072-.108.72-.171 2.015-.297l.676-.066V8.727a3.955 3.955 0 0 1 3.955-3.954h2.832l.04-.5c.117-1.435.175-2.153.283-2.226m5.981 10.55c-1.414.126-2.235.252-2.903.619a4.37 4.37 0 0 0-1.72 1.715c-.372.672-.495 1.499-.62 2.933h2.652a2.59 2.59 0 0 0 2.591-2.591zm-9.136-6.46A2.59 2.59 0 0 0 6.1 8.726v2.627c1.492-.153 2.336-.285 3.017-.669a4.37 4.37 0 0 0 1.688-1.707c.36-.658.483-1.46.605-2.842z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 448 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/*'
+5 -1
View File
@@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') {
document.documentElement.classList.add(theme)
}
export function initSystemColorMode() {
export function initSystemColorMode({additionalBodyClasses = ''} = {}) {
if (additionalBodyClasses) {
document.body.classList.add(additionalBodyClasses)
}
applyTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
+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 -3
View File
@@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
initSystemColorMode()
initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'})
const agent = new AtpAgent({
service: 'https://public.api.bsky.app',
@@ -119,7 +119,7 @@ function LandingPage() {
}, [uri])
return (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
<main className="w-full min-h-dvh flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:text-slate-200">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
@@ -186,7 +186,7 @@ function LandingPage() {
function Skeleton() {
return (
<Container>
<div className="flex-1 flex-col flex gap-2 pb-8">
<div className="flex-1 flex-col flex gap-2 p-5 pb-8">
<div className="flex gap-2.5 items-center">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 dark:bg-slate-700 shrink-0 animate-pulse" />
<div className="flex-1">
+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,
+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
}
}
}
+58 -24
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,6 +260,20 @@ 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.',
},
],
},
],
/**
* Turn off rules that we haven't enforced thus far
*/
@@ -271,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
*/
@@ -279,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>
+161 -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,111 @@ 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["groupName"] 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
)
// The push payload omits the full recipient list for group convos. Without
// any signal of multiple recipients, iOS classifies the notification as
// `MessagingDirect` and ignores `speakableGroupName`. Setting
// `recipientCount` on the donation metadata is Apple's documented escape
// hatch for this case, and overrides the (zero-length) recipients array.
if userInfo["convoKind"] as? String == "group",
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
groupMemberCount > 0 {
let metadata = INSendMessageIntentDonationMetadata()
metadata.recipientCount = groupMemberCount
intent.donationMetadata = metadata
}
// 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.
// Disabled: the composite avatar renders poorly at notification size.
// 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 +202,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["groupName"] 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
@@ -1,4 +1,4 @@
import * as React from 'react'
import {Component, createRef} from 'react'
import {
Dimensions,
type LayoutChangeEvent,
@@ -39,14 +39,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
ref = createRef<any>()
static contextType = PortalContext
@@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component<
function BottomSheetNativeComponentInner({
children,
backgroundColor,
maxHeight,
onLayout,
onStateChange,
nativeViewRef,
@@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
flex: 1,
backgroundColor,
},
maxHeight != null && {maxHeight},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
},
extraStyles,
]}>
<View onLayout={onLayout}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
@@ -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

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