Compare commits

...

331 Commits

Author SHA1 Message Date
Eric Bailey df5f7d7c10 Add gate debug code 2026-04-15 20:34:14 -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
Samuel Newman 5d0b900719 Fix server state query returning undefined (#10232)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:14:21 -05:00
Spence Pope 9b31bb8470 handle single-line text truncation (#10227) 2026-04-12 01:48:58 -07:00
Samuel Newman 02daa0179b use dvh for util_screen_outer (#10230) 2026-04-11 10:08:02 +03:00
pfrazee ff294edd1e Nightly source-language update 2026-04-11 03:11:48 +00:00
bnewbold a7b6cd4504 bskyweb: update to golang v1.26 (#10208) 2026-04-10 10:25:40 -07:00
Samuel Newman 888eca73b3 Refactor chat list implementation (#10059) 2026-04-10 09:58:16 -07:00
Samuel Newman 2c717dc1e7 Rename moderation link in settings to "Moderation and content filters" (#10192) 2026-04-10 09:50:30 -07:00
Artem Pyvovar ff68e4036d Disable keyboard preload on iOS to prevent keyboard flash (#10214) 2026-04-10 09:50:17 -07:00
Misty De Méo 170e169ad1 PostControls: restore likeCount testID (#10221) 2026-04-10 09:50:03 -07:00
Samuel Newman 19df58f1ef Remove direct dependency on @expo/config-plugins (#10152) 2026-04-10 09:49:41 -07:00
Samuel Newman c79dff9b3e Tweak embed styles (#10226) 2026-04-10 09:35:22 -07:00
Spence Pope 13b453e506 [APP-2065] yassify app embed (#10210) 2026-04-10 09:08:09 -07:00
Eric Bailey d8fdfddbaf Check for undefined moderationOpts (#10224) 2026-04-10 10:23:36 -05:00
Eric Bailey ff30d39355 Remove flex to fix view collapse (#10219) 2026-04-10 09:37:06 -05:00
pfrazee 9c65a8a78c Nightly source-language update 2026-04-10 03:14:50 +00:00
Eric Bailey 8c9a11dc3f [APP-1775] Handle labels on actor statuses (#9716) 2026-04-09 15:52:45 -05:00
Samuel Newman d01362a01e Bump version to v1.121.0 (#10215) 2026-04-09 22:34:27 +03:00
DS Boyce 3ff40e9603 Update / hotkey to support all keyboard layouts (#10212) 2026-04-09 14:07:09 -05:00
Eric Bailey cfeac097e1 [APP-2044] Gate image blob size increase (#10178) 2026-04-09 14:06:25 -05:00
Eric Bailey c42940b0ad DM composer fixes (#10213) 2026-04-09 13:47:29 -05:00
Samuel Newman a0d4af5c5f Add font smoothing stuff to embed (#10204) 2026-04-09 06:12:20 -07:00
Samuel Newman 5ad3597fdb Add simple video player to embed (#10205) 2026-04-09 06:12:06 -07:00
pfrazee 987a656904 Nightly source-language update 2026-04-09 03:13:30 +00:00
DS Boyce d0d00ba9f8 Address lint warnings (#10188) 2026-04-08 15:54:43 -07:00
Eric Bailey e4b4e48acb Prep 1.120.0 release (#10207) 2026-04-08 13:58:16 -05:00
RetroSunstar 925ae29b5f Bump expo-paste-input to ^0.1.15 (#10203) 2026-04-08 13:50:12 -05:00
Eric Bailey f6b8854a9b Bump xcode in workflows (#10201) 2026-04-07 21:37:05 -05:00
Eric Bailey 3d0fbc5030 Prep 1.120.0 release (#10200) 2026-04-07 21:06:19 -05:00
Eric Bailey 0a5ae17738 New rich text composer + autocomplete (#10159) 2026-04-07 20:37:50 -05:00
Samuel Newman 02b849996c Double the framerate (#8446) 2026-04-07 14:13:24 -07:00
Samuel Newman f231b67b76 Tweak missing feed copy (#10133) 2026-04-07 15:25:22 -05:00
Samuel Newman d2519a4f67 RM unneeded config plugin, update primary color (#10153) 2026-04-07 15:21:03 -05:00
DS Boyce a049a6538c Use new endpoints for suggested users (#10185) 2026-04-07 15:10:23 -05:00
DS Boyce 3c21fee6c2 Don't call getProfiles if there are no actors (#10196) 2026-04-07 11:55:03 -07:00
DS Boyce e74b57ab78 Update browserslist (#10195) 2026-04-07 11:43:58 -07:00
Samuel Newman a169bd862f Fix layout shift when liking a post on Android (#10190) 2026-04-07 09:48:11 -07:00
Eric Bailey 8bd6d9d135 Revert PostThread parent chunk size for native (#10193) 2026-04-07 10:58:47 -05:00
pfrazee 5868804d3b Nightly source-language update 2026-04-07 03:13:28 +00:00
Eric Bailey bdce8e8ecd Bump expo-paste-input (#10187) 2026-04-06 18:50:28 -05:00
Eric Bailey 99257f2816 Add some more clarity to the RQ docs (#10120) 2026-04-06 18:45:40 -05:00
Spence Pope 011d8d2f7c [APP-2038] fix feeds not refreshing (#10167)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-06 17:34:01 -05:00
Spence Pope c0d3010f3e [APP-1999] Fix ReportDialog Android hang by narrowing dependency arrays (#10171)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:31 -05:00
Samuel Newman 3685439ffb Minor hotkeys nit (#10184) 2026-04-06 07:48:45 -07:00
RetroSunstar 165dd5a779 Fixes Hotkeys causing native to crash immediately on start (#10180) 2026-04-06 09:50:14 -04:00
pfrazee 84b026efb7 Nightly source-language update 2026-04-04 03:09:51 +00:00
Samuel Newman be56066ee9 Fix OOM crash - disable tanstack extension integration (#10169) 2026-04-03 11:31:55 -07:00
DS Boyce cca3326b21 Create global keyboard shortcut handler (#10145) 2026-04-03 10:06:51 -07:00
DS Boyce e0ea778e58 Warn when useState get/set names mismatch (#10166) 2026-04-02 15:47:56 -07:00
Samuel Newman 9fe808f8a8 Fix bottom bar badge text padding (#10162) 2026-04-02 11:51:54 -07:00
Samuel Newman b9f3d04d65 Scale profile badge icons with font size (#10161) 2026-04-02 11:51:38 -07:00
Samuel Newman 68a4d73d61 Add no-extraneous-dependencies and no-nodejs-modules eslint rules (#10151) 2026-04-02 08:34:12 -07:00
Samuel Newman eb566c5fcc Fix useResolveUriQuery (#10163) 2026-04-02 06:39:40 -07:00
pfrazee 512e550c2e Nightly source-language update 2026-04-02 03:13:26 +00:00
Samuel Newman 500fc1c934 Update React Query to latest (#10126) 2026-04-01 15:39:26 -07:00
Arunabh Verma b196ddef73 Replace react-native-paste-input with expo-paste-input (#10158)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-01 16:25:16 -05:00
DS Boyce 5a2135733f Handle more translation errors (#10160) 2026-04-01 13:39:05 -07:00
Samuel Newman 69164c640f Keep screen awake when videos are playing (#10155) 2026-04-01 13:07:02 -05:00
Samuel Newman f30acadc73 Hide alt text note after alt text is added (#10132) 2026-04-01 13:00:11 -05:00
DS Boyce 9b20023c86 Fix Safari layout shift issue with SubtleHover (#10135) 2026-04-01 09:12:11 -07:00
DS Boyce e8ee30398a Update Search to use ALF atoms (#10146)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-01 09:11:27 -07:00
pfrazee 6897116625 Nightly source-language update 2026-04-01 03:13:49 +00:00
DS Boyce 2262040797 Display see more link for suggested users in profile header (#10134) 2026-03-31 14:54:51 -07:00
Samuel Newman 498d321bb1 Change other borderWidths from 2 to 1 (#10156) 2026-03-31 09:35:31 -07:00
Samuel Newman a697841a21 Update typescript-eslint, fix up eslint setup (#10150) 2026-03-31 06:00:56 -07:00
DS Boyce 8ac63d780d Update emoji regex to detect variations and sequences (#10138) 2026-03-30 05:30:33 -07:00
Eric Bailey 0419066f45 Add fmt patch to react-native (#10136) 2026-03-26 22:53:41 -05:00
pfrazee 1463323289 Nightly source-language update 2026-03-27 03:13:48 +00:00
DS Boyce f4e14626aa Add button for creating a new group clip clop (#10066)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-03-26 10:23:49 -07:00
RetroSunstar ce000ada50 Fix 'Lists' & 'Feeds' not displaying their contents on muted profiles (#10101) 2026-03-26 02:49:36 -07:00
pfrazee 4d0774b75e Nightly source-language update 2026-03-26 03:14:00 +00:00
Eric Bailey bc7b6f1e13 Fix clip issue (#10131) 2026-03-25 17:17:47 -05:00
DS Boyce d44060a34e Pass correct log context to suggested user metrics (#10074) 2026-03-25 17:13:23 -05:00
DS Boyce 2c828b5755 Fix text overflow in post meta on web (#10122) 2026-03-25 12:59:04 -07:00
Samuel Newman c06312f09a PostThread perf: reduce time wasted on rendering skeletons (#10092) 2026-03-25 12:44:36 -07:00
Samuel Newman cc5580848c Upgrade TypeScript to 6.0 (#10113) 2026-03-25 00:50:26 -07:00
Samuel Newman fa84c451d0 Fix draft restore to calculate shortened grapheme length for links (#10103)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 15:31:19 -05:00
Paweł Fornagiel 894e2b89d4 fix: too many re-renders when toggling experimental feed sampling option (#10114) 2026-03-24 14:59:01 -05:00
Eric Bailey 3e37696d88 Add resolution downsizing to image compression (#10117) 2026-03-24 14:34:51 -05:00
Eric Bailey 6a15ca88b6 Update redirect service (#10116) 2026-03-24 10:10:35 -05:00
Eric Bailey 5a6942025c Add metrics to redirect service (#10112) 2026-03-24 08:45:08 -05:00
pfrazee 3866142ccd Nightly source-language update 2026-03-24 03:09:20 +00:00
Samuel Newman 7fd6f8f04b Update version to v1.120.0 (#10110) 2026-03-23 22:05:43 +02:00
DS Boyce 21d8b07bfe Use isTranslationSupported from expo-translate-text (#10108) 2026-03-23 09:56:35 -07:00
Samuel Newman f08bf5fef9 Fix report dialog flashing error state on open (#10099) 2026-03-23 09:19:57 -07:00
Samuel Newman 3e7e859c9c [Web] Fix Wordle score rendering - prevent Inter from overriding emoji presentation (#10104) 2026-03-23 09:19:33 -07:00
Samuel Newman 854ae60e7b [Web] Fix clipped text when using numberOfLines={1} (#10105) 2026-03-23 09:17:49 -07:00
Eric Bailey cc4a436e45 Bump version to 1.119.1 (#10088) 2026-03-20 12:04:12 -05:00
Eric Bailey 0532e120b8 Prep 1.119.1 (#10087) 2026-03-20 11:52:46 -05:00
Samuel Newman 288fad67f4 Fix bottom sheet crash on API <30 (#10084)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:41:39 -05:00
DS Boyce 0e1e790c34 Include recId with log event for suggested profiles (#10075) 2026-03-20 11:39:51 -05:00
Eric Bailey 74d8ca8fa7 Increase parent batch size to reduce onStartReached calls (#10086) 2026-03-20 11:38:52 -05:00
Eric Bailey 34d8c6fe58 [APP-1968] Increase image upload resolution (#10073) 2026-03-20 11:22:36 -05:00
Samuel Newman 5bcb909081 Remove hardcoded yellows (#10083) 2026-03-20 11:21:16 -05:00
Samuel Newman e28f6d2f37 Add bookmarks to thread placeholder candidates (#10080)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:20:27 -05:00
DS Boyce 3e7d7ce5f4 Fix issue with site loading on older versions of Safari (#10077) 2026-03-19 15:50:25 -07:00
DS Boyce 876e20166a Ensure mute state stays in sync after mutation (#10067) 2026-03-19 06:18:48 -07:00
pfrazee 2a87f6f6b5 Nightly source-language update 2026-03-19 03:12:12 +00:00
Eric Bailey bfd2d20192 Add back starter packs button in ProfileMenu (#10069) 2026-03-18 18:29:22 -05:00
Eric Bailey dc8570c524 Bump keyboard controller and add missing provider on web (#10068) 2026-03-18 15:43:22 -05:00
Samuel Newman 530afe87c7 web: LRU screen cache to bound memory in long sessions (#10063)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:50:48 -07:00
Eric Bailey 317ce2b3da Prep 1.119.0 (#10062) 2026-03-17 16:16:01 -05:00
Eric Bailey 45df50ec19 Rename translation properties for clarity (#10061) 2026-03-17 13:59:28 -05:00
surfdude29 b9a3256e51 Don't strip query parameters on in-app navigation (#9948) 2026-03-17 13:43:37 -05:00
DS Boyce 1386a559b7 Log languages a post is tagged with after translating (#10014)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-17 12:31:23 -05:00
RetroSunstar be0d00de17 Set displayName and description to undefined if no values are provided during a profile edit (#10016) 2026-03-17 12:04:27 -05:00
RetroSunstar dbba97f28d Use proper constants.ts exports for Edit Profile Dialog limits (#10054) 2026-03-17 12:04:04 -05:00
Samuel Newman 5e8ef6aa9b Adjust VTT cue line to avoid occlusion by video controls (#10017)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:05:58 -05:00
surfdude29 177bdcd2b7 [bskylink] URL-encode apostrophes before HTML escaping (#9559) 2026-03-16 11:18:23 -07:00
pfrazee 2027589c55 Nightly source-language update 2026-03-14 03:04:35 +00:00
DS Boyce b8d60eb0e2 Address lint warnings in List.web.tsx (#10046) 2026-03-13 09:41:38 -07:00
DS Boyce 97fdd7c59b Create new useSuggestedFollowsByActorWithDismiss hook (#10048) 2026-03-13 09:18:23 -07:00
pfrazee 6afe48db6a Nightly source-language update 2026-03-13 03:06:17 +00:00
DS Boyce 0b6ff8000d Remove getSuggestedFollowsByActor fallbacks and use recIdStr (#9988) 2026-03-12 16:57:38 -07:00
DS Boyce 35cb2bcf94 Create codemod for updating Toast calls to v2 (#10045) 2026-03-12 11:35:58 -07:00
DS Boyce 9c9970f680 Don't show chat notification if viewer is age gated (#10042) 2026-03-12 11:21:57 -07:00
DS Boyce 7b5d5a4f76 Provide age-restricted viewers the option to delete their account (#10041) 2026-03-12 11:13:23 -07:00
Samuel Newman 4737bfb7ed Update expo deps, unpin things (#10044) 2026-03-12 05:33:11 -07:00
pfrazee 374ce2c39e Nightly source-language update 2026-03-12 03:08:04 +00:00
DS Boyce 1db01a09a8 Create codemod for addressing ESLint warnings (#10032) 2026-03-11 15:51:31 -07:00
Samuel Newman 80429ec902 Add “download image” option to web, simplify downloads (#10025) 2026-03-11 23:44:19 +02:00
heikadog 0c7b2d5353 Update issue templates to take uploads, and fix typo (#10036) 2026-03-11 14:50:35 -05:00
DS Boyce 850e6e6f52 Add new metrics for post menu interactions (#10022)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-11 13:30:33 -05:00
Eric Bailey 0937f522af Serve og:video tags on post URLs (#10039) 2026-03-11 11:46:27 -05:00
pfrazee f5994af620 Nightly source-language update 2026-03-11 03:04:18 +00:00
Samuel Newman 8b67a3ec2e First pass at fixing large type by adding maxFontSizeMultiplier (#10037) 2026-03-10 23:39:02 +02:00
Samuel Newman 6187e79b0e Update React Native Screens and React Navigation to latest (#9993)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-10 15:32:41 -05:00
Spence Pope 2bd9811652 [APP-1928] add bot/automated account badge and self-labeling settings (#10008)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-10 13:28:49 -05:00
pfrazee 8bdb1c30c8 Nightly source-language update 2026-03-10 03:05:11 +00:00
Samuel Newman aa897f55a0 The Great Unjanking of the Sheets (#9973) 2026-03-09 22:53:32 +02:00
devin ivy 18d7e775f6 ogcard: ensure we use jpeg images for satori compat (#10023) 2026-03-07 09:11:57 +00:00
pfrazee b8aae166d9 Nightly source-language update 2026-03-07 02:59:25 +00:00
DS Boyce f8886fbfe6 Refine presentation of translation results (#10015)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-06 13:36:58 -08:00
Samuel Newman 041e348581 Bump version to 1.119.0 (#10020) 2026-03-06 13:34:03 -08:00
Samuel Newman e2c54a858c Move dev-env to subdirectory (#10009) 2026-03-06 11:45:49 -06:00
Eric Bailey 60a0edbbe2 [APP-1960] Fix current session refresh bug (#10019) 2026-03-06 11:43:14 -06:00
pfrazee 562bf3be22 Nightly source-language update 2026-03-06 03:06:49 +00:00
DS Boyce 290e0f2b54 Disable animations for translation events on Android (#10013) 2026-03-05 15:01:29 -08:00
DS Boyce ece6dc251c Link to profile via handle vs did (#10012) 2026-03-05 12:52:39 -08:00
Eric Bailey 8c705864a2 Prep 1.118.1 patch relase (#10011) 2026-03-05 14:43:18 -06:00
Eric Bailey 7a08b82810 Fix deps (#10010) 2026-03-05 14:11:57 -06:00
Samuel Newman 20bf2cd117 Prevent over-eager translate button (#10007) 2026-03-05 12:07:34 -08:00
DS Boyce fe8e8ce7de Add README.md for lib/translation (#10005) 2026-03-05 10:53:25 -08:00
Eric Bailey 8b8acb7bd1 Add docs on directory structures and naming conventions (#10002) 2026-03-05 12:31:55 -06:00
DS Boyce 1a487d0943 Remove duplicate punctuation (#10004) 2026-03-05 10:16:20 -08:00
Samuel Newman 1ae77aa997 Fix findNodeHandle usage on web (#10001) 2026-03-05 07:22:13 -08:00
Samuel Newman 7cec490cef Use a different icon for testflight builds (#10000) 2026-03-05 07:04:59 -08:00
Eric Bailey 1281eb01e6 Prep 1.118.0 (#9999) 2026-03-04 21:08:55 -06:00
pfrazee 4339585b27 Nightly source-language update 2026-03-05 03:06:58 +00:00
Samuel Newman 30c9ea5789 v1.118.0 E2E tests (#9994) 2026-03-04 20:45:08 -06:00
DS Boyce afbade6f6f Refine UX for on-device translation for posts (#9987)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-04 20:37:44 -06:00
Samuel Newman 1782a65174 Add iOS 26 fluid zoom transition to alt text dialog (#9970)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 20:32:08 -06:00
Eric Bailey 7fd218c97d Update API package to fix stale session issues (#9998) 2026-03-04 18:27:12 -06:00
DS Boyce 8446efb739 Pass feed URI to sendInteractions endpoint (#9989) 2026-03-04 12:38:24 -08:00
pfrazee b84f28f884 Nightly source-language update 2026-03-04 03:05:02 +00:00
DS Boyce 7dc3b4fa8e Add search event analytics (#9949)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-03 14:49:38 -06:00
Samuel Newman 37a82761f5 Cache profile view before opening AfterReportDialog (#9962) 2026-03-03 13:50:22 -06:00
Samuel Newman 612a778361 Set mute word dialog text input return key type to "done" (#9967)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 13:35:09 -06:00
Samuel Newman 162a78e1ae Add 🪿 (#9983) 2026-03-03 06:06:13 -08:00
pfrazee c42502fdcb Nightly source-language update 2026-03-03 03:09:48 +00:00
Samuel Newman 8b1c47a499 bskyweb: use avatar thumbnail for post og:image fallback (#9923) 2026-03-02 22:28:16 +00:00
Samuel Newman c2fd87bd8b Remove JPG hardcoding from image processing systems (#9955) 2026-03-02 20:23:07 +00:00
Samuel Newman d247a65683 Add Trans to string (#9981) 2026-03-02 11:39:50 -08:00
Spence Pope a95b877ea3 [APP-1917] fix header measurement for android content disappearing on switch (#9964)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-03-02 14:20:16 -05:00
Samuel Newman f614bf650d Translation followups (#9980) 2026-03-02 11:01:09 -08:00
Samuel Newman 5ee667f307 Force relayout to fix stuck header blur (#9979) 2026-03-02 10:50:00 -08:00
Spence Pope 9c29b2867a [APP-1882] fix email not updating in session state after email change (#9953) 2026-03-02 06:09:21 -08:00
smileyhead 9746dd8e4c Make ALT labels translatable (#9976) 2026-03-02 13:14:07 +00:00
DS Boyce 8e2a5abfff Fix crash with translate link on web (#9972) 2026-02-28 23:53:42 +00:00
pfrazee 6cdc1fe2b5 Nightly source-language update 2026-02-28 02:56:49 +00:00
DS Boyce 9bbcb472ef Use on-device translation on mobile when available (#9930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 16:59:19 -08:00
Samuel Newman 7c9f05a2af ContextMenu - return item to the right location if keyboard hides (#9963) 2026-02-27 22:19:04 +00:00
Samuel Newman d4357b2cb8 Fix iOS sheet keyboard handling using native scrollview features (#9959) 2026-02-27 13:59:24 -08:00
Spence Pope 576761a645 [APP-1819] fix progress guide flash and extra gap (#9960) 2026-02-27 08:31:35 -08:00
DS Boyce 8a1f8997fe Fix infinite loading spinner when changing search terms (#9950)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 03:42:31 -08:00
pfrazee 6cb88ce1f3 Nightly source-language update 2026-02-27 03:06:31 +00:00
Samuel Newman 65c4b7833d Blur effect on home screen (iOS 26) (#9935) 2026-02-26 07:00:12 -08:00
Spence Pope 9b24d75c9c [APP-1876] Fix pull-to-refresh getting stuck on tab switch (#9951) 2026-02-26 02:06:30 -08:00
Spence Pope 398df1a4b5 ensure app session state refreshes on reactivation (#9954)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:06:16 -08:00
pfrazee db9c2a1596 Nightly source-language update 2026-02-26 03:07:29 +00:00
Alex Benzer 00816b70dc [APP-1859] pinned feed drag n drop (#9893)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: vineyardbovines <spencerfpope@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:52:10 -08:00
Samuel Newman 49272be36b Fix up polyfills, remove unused deps (#9946) 2026-02-25 11:43:46 -08:00
surfdude29 85ffc77983 Show hashtag symbol for Mute option in menu (#9942) 2026-02-25 01:18:20 -08:00
pfrazee 95bede2dcb Nightly source-language update 2026-02-25 03:09:49 +00:00
surfdude29 e656fe688d Align strings from 'subtitles' to 'captions' (#9941) 2026-02-24 21:56:12 +00:00
Samuel Newman db1f45de92 iOS 26 tweaks #2 (#9938) 2026-02-24 12:14:19 -08:00
Samuel Newman bc61314c41 iOS 26 followups (#9936) 2026-02-24 08:12:57 -08:00
mozzius 1850042c37 Nightly source-language update 2026-02-24 07:17:35 +00:00
Samuel Newman 782edbf281 Restore type assert that got accidentally removed (#9934) 2026-02-23 23:11:39 -08:00
Samuel Newman 73b096e443 iOS 26 (#9047)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:34:52 -08:00
pfrazee 9b0f0a8d24 Nightly source-language update 2026-02-24 03:09:35 +00:00
Samuel Newman 9ec06971af Upgrade Lingui to v5 (#9905) 2026-02-23 20:28:03 +00:00
Samuel Newman 75242b9b1f Bump version to v1.118.0 (#9931) 2026-02-23 12:04:58 -08:00
pfrazee 21e53b226d Nightly source-language update 2026-02-22 03:10:59 +00:00
surfdude29 02c2a9ea7c Mark admonition text for translation in CreateListFromStarterPackDialog (#9922) 2026-02-21 16:53:44 +00:00
Eric Bailey cb7e2ab976 Release prep 1.117.0 (#9920) 2026-02-20 14:30:14 -06:00
Eric Bailey 067c6dacc9 Update location copy (#9918) 2026-02-20 13:20:04 -06:00
Eric Bailey 9b8c46cdb7 Add A/A test (#9917) 2026-02-20 13:19:56 -06:00
Samuel Newman f3cc850b62 Fix up typescript config (#9910) 2026-02-20 18:07:17 +00:00
Samuel Newman 35f9ed82b9 Fix string concat bug with trending topics muteword moderation (#9914) 2026-02-20 11:29:08 -06:00
Samuel Newman 8c4fc087f8 Add ability to convert starter pack to list (#9675)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@users.noreply.github.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-20 11:27:55 -06:00
Samuel Newman f3ddc074a6 Fork MCEmojiPicker (#9763) 2026-02-20 10:39:48 -06:00
Spence Pope 5e0ffc0917 [APP-1809] add cleanup button for unavailable moderation services (#9901)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-20 10:23:01 -06:00
Samuel Newman 3fae42c077 Remove hardcoded warning color, add yellow and pink to ALF palette (#9911) 2026-02-20 10:17:11 -06:00
Sylwia 80c0aa35e2 Add CORS headers to /oembed API (#9845) 2026-02-19 22:07:49 -08:00
Eric Bailey 4e3c3e9905 [APP-1805] Analytics tweaks and tracking callbacks (#9861) 2026-02-19 15:31:35 -06:00
pfrazee 1565e071c1 Nightly source-language update 2026-02-19 03:10:25 +00:00
Samuel Newman c906fea77a Fix webpack dev server freezing after repeated refreshes (#9865) 2026-02-18 11:03:23 -08:00
Samuel Newman 775a041b71 Add chat data export to Export My Data dialog (#9900)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:58 -08:00
Samuel Newman 98d17fd950 Fix native-only function being called on web (#9904) 2026-02-18 09:46:45 -08:00
Samuel Newman 41b3742365 Dynamically load date-fns locale data (#9535) 2026-02-18 11:30:32 -06:00
Samuel Newman 512ce3d5d0 ALF content language dialog (#9471)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-18 08:57:32 -08:00
Samuel Newman bdf143ce79 Restructure notification/linking handling to use synchronous APIs (#9497)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-18 08:20:39 -08:00
Samuel Newman adc079e095 Get rid of Sentry Replay from web bundle (#9534) 2026-02-18 10:20:05 -06:00
DS Boyce 74330500de Improve error handling on Explore page (#9890) 2026-02-18 09:57:31 -06:00
DS Boyce ac939dc84e Update onboarding user suggestions with new endpoint (#9872) 2026-02-18 09:35:00 -06:00
Samuel Newman b12d703353 Enable profile hover card on quoted post authors (#9896)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:38 -08:00
DS Boyce c188fd4f04 Log onboarding errors for suggested accounts (#9892) 2026-02-18 00:18:22 -08:00
Samuel Newman a1cda126e6 ALF feed error screen (#9895) 2026-02-18 00:16:12 -08:00
pfrazee ae115ca17e Nightly source-language update 2026-02-18 03:10:48 +00:00
Spence Pope 197c069416 Clear link and gif caches to avoid pointing at missing files (#9898)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:39:34 -05:00
pfrazee e67d81f269 Nightly source-language update 2026-02-17 03:09:32 +00:00
DS Boyce 7c08bba6cf Use Prompt.Action for delete account buttons (#9889) 2026-02-16 16:23:27 -08:00
DS Boyce 29bc0f1372 Add optional props to Prompt.Action (#9887) 2026-02-16 13:29:25 -08:00
surfdude29 82f42e734c Wrap interaction counts with Trans to add translation flexibility (#9036) 2026-02-16 21:12:26 +00:00
surfdude29 3b9f0b0a68 Add plural formatting for several strings (#9576) 2026-02-16 21:10:57 +00:00
Samuel Newman a6ba0810b7 Be more descriptive with video errors, log less (#9886) 2026-02-16 21:09:49 +00:00
DS Boyce 49253dbd88 Refactor delete account dialog using ALF (#9863) 2026-02-16 12:00:46 -08:00
Eric Bailey cc2255cc49 [APP-1776] Refactor Live Now config, reorg files (#9871) 2026-02-16 11:44:22 -06:00
Eric Bailey 8ee9709fbb [APP-1764] Improve Age Assurance copy (#9885)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-16 11:03:59 -06:00
Samuel Newman 90d650def2 Use video thumbnail as og:image for posts with video embeds (#9877) 2026-02-16 15:35:29 +00:00
Samuel Newman 79e27e303b Fix missing pixel row in text rendering on iOS (#9882)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:41:35 -08:00
Spence Pope 6be7751769 have back button dismiss lightbox on web (#9874) 2026-02-16 08:39:53 -05:00
Samuel Newman 9b830fd425 Use scroll position to gate post-created feed refresh (#9881)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:39:29 -08:00
Samuel Newman 4ebeb94b41 Add Renew menu for expired mute words (#9883)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:30:07 -08:00
Samuel Newman b6b364700e Make profile description text selectable (#9884)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:29:59 -08:00
pfrazee 95f9cc1ed5 Nightly source-language update 2026-02-16 03:11:39 +00:00
surfdude29 06fcea988e 'Log in' to 'Sign in' (#9875) 2026-02-15 19:02:01 +00:00
pfrazee b166d395d9 Nightly source-language update 2026-02-15 03:11:50 +00:00
Samuel Newman 1228306309 Fix quote embed clickability in composer on web (#9876) 2026-02-14 22:29:59 +00:00
pfrazee bc001168f3 Nightly source-language update 2026-02-14 03:05:51 +00:00
Spence Pope b4cb4c4d13 Revert "Add --what-to-test to iOS submissions (#9856)" (#9873) 2026-02-13 13:24:06 -08:00
Samuel Newman 2e34f965fc [Auth rework] Add a header to login flow (#9368) 2026-02-13 20:15:36 +00:00
Spence Pope 9cdfb40ce7 dedupe trending topics (#9870) 2026-02-13 13:03:36 -05:00
Samuel Newman c83dddce57 Bandcamp embed (#9445) 2026-02-13 13:30:10 +00:00
pfrazee 795428971d Nightly source-language update 2026-02-13 03:12:46 +00:00
Spence Pope 82fea6da7d [APP-1846] Remove trending post counts until we can improve accuracy (#9867) 2026-02-12 19:34:50 +00:00
Spence Pope 2f156bb906 [APP-1833] Handle errors on saving a draft over char limit (#9850)
* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* pr comment fixes

* pluralization
2026-02-12 09:24:38 -06:00
Samuel Newman c46219bc4d Fix keyboard flicker when selecting a draft in composer (#9864) 2026-02-12 15:07:26 +00:00
pfrazee 6d68a5bd21 Nightly source-language update 2026-02-12 03:12:33 +00:00
surfdude29 cbac380c96 Add profile error messages to cleanError (#9842) 2026-02-11 19:55:54 -06:00
Samuel Newman 8c4820b1f1 [Web] Fix splash flicker and add animation (#9860)
* manually hide non-React splash logo

* tweak comment

* animate out splash
2026-02-11 19:52:22 -06:00
Samuel Newman 93d2f3a8db Germ DM button (#9848)
* germ link (wip)

* add todos

* just yeet declaration for now

* ensure not proxied

* allow undo delete

* tweak styles

* ignore unknown values

* tweak styles

* fix boolean logic

* skip IAB

* fix mutationFn

* add link warning interstitial

* Logging and metrics

* Little more error handling

* Update copy

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-11 17:51:19 -06:00
Eric Bailey 3a9526d55e Bump API SDK (#9862)
* Bump API SDK

* Bump dev-env
2026-02-11 17:41:34 -06:00
Spence Pope 830f305d2d Merge pull request #9823 from bluesky-social/app-1788
[APP-1788] update design for stacked avatars
2026-02-11 15:57:07 -05:00
DS Boyce 8a088e8710 Merge pull request #9853 from bluesky-social/d/profile-screen
Use Tanstack Query isPending over isLoading
2026-02-11 10:27:13 -08:00
Spence Pope 8cbe3985fa Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:30 -05:00
Spence Pope 1ec70420e6 Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:24 -05:00
Spence Pope 63fbf577d9 Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:15 -05:00
DS Boyce 355868e01e Address feedback and lint warnings 2026-02-11 09:46:47 -08:00
Samuel Newman 37e5a0f7a4 Clean up starter packs dialog styles + add optimistic updates (#9469) 2026-02-11 17:46:28 +00:00
DS Boyce 4356ff0040 Merge branch 'main' into d/profile-screen 2026-02-11 09:43:55 -08:00
Samuel Newman 4eca13941a Fix GIFs breaking mute state/pausing phone audio (#9859)
* fix operator precedence mistake

* ensure gifs can't change the global mute state
2026-02-11 08:51:23 -08:00
Samuel Newman 8580e4a00e absolutely position header, set a headerheight (#9858) 2026-02-11 07:22:13 -08:00
Samuel Newman 8581285d34 simplify some conditionals 2026-02-11 16:20:48 +02:00
Samuel Newman 57997d6d16 Add --what-to-test to iOS submissions (#9856)
Sets the "What to test" field in TestFlight to distinguish between
TestFlight and Production builds.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 06:01:27 -08:00
Samuel Newman a25bc8f249 Debounce thread preferences on leading edge, reduce to 2s (#9851)
* debounce on leading edge, reduce to 2s

* flush debounce on leave screen

* simplify by using useMutation properly
2026-02-10 23:41:04 -08:00
pfrazee 177a6e50bd Nightly source-language update 2026-02-11 03:12:22 +00:00
DS Boyce 5459857112 Use Tanstack Query status over isPending 2026-02-10 13:27:18 -08:00
Samuel Newman 338016ed5c Android sheets edge to edge (#8342) 2026-02-10 21:01:58 +00:00
Samuel Newman e2058c8ecc Update starter pack message to clarify users joined Bluesky (#9849) 2026-02-10 15:37:48 +00:00
surfdude29 3cbded0269 fix typos in drafts NUX alt text (#9832) 2026-02-10 10:41:47 +00:00
Samuel Newman 5ea96736de rm unused @testing-library/jest-native (#9820) 2026-02-10 08:36:27 +00:00
pfrazee efed955003 Nightly source-language update 2026-02-10 03:12:38 +00:00
surfdude29 b8db36afb0 Add context for translators for "No one" string (#9844)
* add context for translators 'No one' option

* update ActivityPrivacySettings to add context for translators

* update translation context for 'No one' option
2026-02-09 15:37:04 -08:00
Samuel Newman 5642bc40bb bump version to v1.117.0 (#9847) 2026-02-09 15:14:46 -08:00
Samuel Newman 3be1f5caa9 Add attribution for starter pack follows (#9834) 2026-02-09 10:01:55 -06:00
pfrazee 18eea81e76 Nightly source-language update 2026-02-08 03:12:29 +00:00
Samuel Newman 917f099e26 [Drafts] Some bugs (#9833)
* Fix text input not updating

* Fix autofocus

* make placeholder text fainter

* Await invalidation

* Image only drafts

* tweaks to gif presentation

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-07 15:38:48 +00:00
pfrazee 6bfe758d2a Nightly source-language update 2026-02-06 03:06:58 +00:00
Spence Pope 18ab5d6f34 [APP-1823] Fix 'create account' / 'sign in' buttons stacking (#9830)
* ensure enough space for sign in buttons

* smallest possible adjustment

* smallest possible adjustment
2026-02-05 08:40:25 -08:00
Spence Pope dc3f01591f [APP-1794] respect mute words for live event feed banners (#9831)
* respect mute words for live event feed banners

* linting
2026-02-05 09:36:19 -06:00
vineyardbovines 3a28a07081 Merge branch 'main' into app-1788 2026-02-05 08:59:22 -05:00
vineyardbovines a57faedbae update icon 2026-02-05 08:51:53 -05:00
pfrazee 423eddc474 Nightly source-language update 2026-02-05 03:07:42 +00:00
Darrin Loeliger 5c5b365ee9 Adding little message at the bottom of the drafts list (#9827)
* Add encouragement message to drafts list

Shows "So many thoughts, you should post one" at the bottom of the drafts list when user has more than 5 drafts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Use Text component and center-align encouragement message

- Switch from ButtonText to Text component for better styling
- Add text-center alignment to the message

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 16:40:16 -06:00
Eric Bailey 1584c2de48 intl (#9826) 2026-02-04 15:49:33 -06:00
Samuel Newman bb6ce83198 [Drafts] NUX (#9825)
* drafts NUX

* use "Drafts" string

* change button copy
2026-02-04 15:39:55 -06:00
Samuel Newman 3abc361800 Exempt gifs from active video system (#9824) 2026-02-04 12:37:09 -08:00
Samuel Newman e6c4a539ca align alt text behaviour between gif types (#9822) 2026-02-04 12:34:14 -08:00
Eric Bailey 965b17ff86 Update android splash colors (#9821) 2026-02-04 14:07:18 -06:00
vineyardbovines a148b2477b update design for stacked avatars 2026-02-04 15:06:42 -05:00
Eric Bailey 546201c93c intl pull and extract:all (#9818) 2026-02-04 13:14:22 -06:00
Samuel Newman b3cbb3440a Make GIFs look like GIFs (#9809)
* set presentation field in record

* refer to it as a gif in the composer

* video player gif presentation style

* tweak badge

* only do the "manual loop" when absolutely necessary

* mute gifs

* reuse gif controls component for tenor gifs

* update media previews in notifications

* edit comment

* remove outdated prop
2026-02-04 13:05:17 -06:00
Samuel Newman da5c356fc7 Only treat animated gifs as videos, leave static gifs as images (#9814)
* only treat *animated* gifs as videos

* fix export
2026-02-04 13:00:29 -06:00
Eric Bailey 6f01503eac intl pull and extract:all (#9817) 2026-02-04 12:52:39 -06:00
pfrazee 24dbe799dc Nightly source-language update 2026-02-04 03:06:15 +00:00
Samuel Newman 3197cbcf36 disable saving drafts for replies (#9815) 2026-02-03 09:32:13 -08:00
Samuel Newman 017120b3e8 Add recId to suggestedUser:* events (#9764)
* add recId to onboarding

* add recId to follow dialog

* add recId to profile header suggestions

* add recId to feed interstitials, fix animation on native

* fix claude feedback

* fix yarn.lock ci
2026-02-03 10:42:12 -06:00
Samuel Newman 27d9462639 Add animation to content hider (#9812)
* add layout animation to contenthider

* add layout animation to posthider
2026-02-03 08:03:36 -08:00
Samuel Newman b2c95bc35a remove superjson (#9811) 2026-02-03 07:52:19 -08:00
pfrazee cbd48c855a Nightly source-language update 2026-02-03 03:09:12 +00:00
Samuel Newman 421c18ea7c fix image patch version (#9810) 2026-02-02 15:35:04 -08:00
Samuel Newman 26c84399dd Add light haptic feedback to Edit Profile button (#9787)
https://claude.ai/code/session_018ZjPcCUDPfTjQ5SGac3Zgs

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 15:33:55 -08:00
Samuel Newman 4f1d4821c5 Speed up startup by persisting some queries (#9594)
* persist startup queries

* Use IDB for query storage (#9687)

* Add storage abstraction for persisted query data

Introduce a platform-specific storage abstraction layer for react-query
persistence:
- Native: Uses MMKV for high-performance synchronous storage
- Web: Uses IndexedDB via the `idb` library for efficient async storage

This replaces the previous AsyncStorage implementation with more performant
platform-native solutions. The abstraction maintains API compatibility with
@tanstack/query-async-storage-persister.

* Refactor storage abstraction to use factory pattern

Change createPersistedQueryStorage to a factory function that accepts a
storage ID, allowing multiple isolated storage instances:
- Native: Each instance gets its own MMKV store
- Web: Each instance gets its own IndexedDB database

Adopt the factory pattern in:
- react-query.tsx: Uses 'persisted_queries' storage
- ageAssurance/data.tsx: Uses 'age_assurance' storage

This provides better separation between different query client caches
and allows each to be managed independently.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Refactor to use archival storage

(cherry picked from commit a773b40e41c96f821cd32260919ce1437c0fc3ab)

* Improve archive db types

(cherry picked from commit 80e4959ba2aa00c984c26aed2f7dfae1095720b0)

* rm idb

* clear on logout, bust on app version

* create abstraction for persisting queries, make gcTime infinite

* Rm abstraction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-02 11:55:01 -06:00
mozzius bfd5ed480f Nightly source-language update 2026-02-01 10:24:34 +00:00
Samuel Newman d9ef02737b Rename "lightbox" to "image viewer" in user-facing strings (#9807)
https://claude.ai/code/session_01LWKpUAY5WJL7UfeFJd9kVU

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 12:21:15 +02:00
mozzius 809b318fe4 Nightly source-language update 2026-02-01 08:10:57 +00:00
Samuel Newman 864af57fd9 Fix i18n string interpolation for device name (#9806)
Use a separate translatable string for "Media stored on another device"
instead of interpolating a translated fragment into another string.
This allows translators to properly handle the complete sentence for
languages with different grammar structures.

https://claude.ai/code/session_01TJMLcXE9HHneEXMEBJqL4u

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 00:08:28 -08:00
Eric Bailey ab98130295 Rm entitlement for now (#9804) 2026-01-30 20:30:00 -06:00
estrattonbailey a168e97d07 Nightly source-language update 2026-01-31 02:20:20 +00:00
Eric Bailey 748706daab Draft previews (#9803)
* Send deviceId and platform

* Add deviceId and deviceName to drafts, skip loading media for other devies

* WIP new preview

* show rich text in drafts list

(cherry picked from commit fb70d53d59)

* New draft preview UI

* Tighten up spacing in draft list

* Add i18n comments

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-30 17:00:54 -06:00
Spence Pope 45d6e50eb4 lint (#9802) 2026-01-30 22:48:31 +02:00
Eric Bailey e2a56b0191 Drafts fixes (#9795)
* Allow saving quote post drafts

* Remove warning logs for failed media loading

* Fix threadgates
2026-01-30 10:26:32 -06:00
Samuel Newman 1eb09b7210 Detect facets when composer is opened with mention (#9625)
* highlight the initial mention

* fix mention initalisation on web
2026-01-30 10:14:37 -06:00
Samuel Newman ee99320e1f fix copy-to-cache (#9799) 2026-01-30 09:47:35 -06:00
Samuel Newman 9689a6daaa add 2px margin below post text (#9748) 2026-01-30 05:44:04 -08:00
1018 changed files with 231887 additions and 180644 deletions
+3
View File
@@ -46,3 +46,6 @@ GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# app-config web worker URL
APP_CONFIG_DEV_URL=
+3 -2
View File
@@ -1,5 +1,5 @@
name: "Bug Report"
description: "Create a report for an issue you have experience in the app."
description: "Create a report for an issue you have experienced in the app."
labels: ["bug"]
body:
- type: markdown
@@ -19,13 +19,14 @@ body:
4. See error
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+2 -1
View File
@@ -26,13 +26,14 @@ body:
4. See error
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+2 -1
View File
@@ -15,7 +15,7 @@ body:
implement it in a timely manner.
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
@@ -24,6 +24,7 @@ body:
in or is missing from.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: textarea
attributes:
label: Describe Alternatives
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0"
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
@@ -197,7 +197,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0"
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
+3 -1
View File
@@ -110,7 +110,9 @@ google-services.json
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
src/locale/locales/**/messages.js
src/locale/locales/**/messages.mjs
src/locale/locales/**/messages.ts
# local builds
*.apk
+135
View File
@@ -0,0 +1,135 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import React from 'react'
* React.useEffect(() => {}, [])
*
* After:
* 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
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find the React import
let reactImportPath = null
const reactMembers = new Set()
root.find(j.ImportDeclaration).forEach(path => {
const node = path.value
if (node.source.value === 'react') {
node.specifiers.forEach(spec => {
// Check if this is a default import of React
if (
spec.type === 'ImportDefaultSpecifier' &&
spec.local.name === 'React'
) {
reactImportPath = path
}
})
}
})
if (!reactImportPath) {
// No React import found, nothing to do
return file.source
}
// Find all React.* member expressions
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// Find all React.* JSX member expressions (e.g., <React.Fragment>)
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// If no React members are used, remove the import
if (reactMembers.size === 0) {
reactImportPath.prune()
return root.toSource()
}
// Sort the members for consistent output
const sortedMembers = Array.from(reactMembers).sort()
// Create new import specifiers
const newSpecifiers = sortedMembers.map(name =>
j.importSpecifier(j.identifier(name), j.identifier(name)),
)
// Get the existing import specifiers
const sortedImports = Array.from(reactImportPath.value.specifiers).sort()
const existingSpecifiers = sortedImports.filter(
specifier => specifier.type !== 'ImportDefaultSpecifier',
)
const allSpecifiers = [
...new Map(
[...existingSpecifiers, ...newSpecifiers].map(item => [
item.imported.name,
item,
]),
).values(),
]
// Update the import declaration
reactImportPath.value.specifiers = allSpecifiers
// Replace all React.* member expressions with just the identifier
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.replaceWith(path => {
return j.identifier(path.value.property.name)
})
// Replace all React.* JSX member expressions with just the identifier
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.replaceWith(path => {
return j.jsxIdentifier(path.value.property.name)
})
return root.toSource()
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import * as Toast from '#/view/com/util/Toast'
* Toast.show(message, 'xmark')
*
* After:
* 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
*/
/* eslint-disable */
export const parser = 'tsx'
const OLD_IMPORT = '#/view/com/util/Toast'
const NEW_IMPORT = '#/components/Toast'
const convertLegacyToastType = type => {
switch (type) {
// these ones are fine
case 'default':
case 'success':
case 'error':
case 'warning':
case 'info':
return type
// legacy ones need conversion
case 'xmark':
return 'error'
case 'exclamation-circle':
return 'warning'
case 'check':
return 'success'
case 'clipboard-check':
return 'success'
case 'circle-exclamation':
case 'exclamation-circle':
return 'warning'
default:
return 'default'
}
}
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find Toast import declarations using the old path
const toastImports = root
.find(j.ImportDeclaration)
.filter(path => path.value.source.value === OLD_IMPORT)
if (toastImports.length === 0) {
return file.source
}
// Update import path
toastImports.forEach(path => {
path.value.source.value = NEW_IMPORT
})
// Collect all local names the Toast namespace is bound to
const toastLocalNames = new Set()
toastImports.forEach(path => {
path.value.specifiers.forEach(spec => {
if (spec.type === 'ImportNamespaceSpecifier') {
toastLocalNames.add(spec.local.name)
}
})
})
// Transform Toast.show(message, type) calls
root.find(j.CallExpression).forEach(path => {
const {callee, arguments: args} = path.value
// Match <ToastName>.show(...)
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
!toastLocalNames.has(callee.object.name) ||
callee.property.name !== 'show'
) {
return
}
// Only transform 2-arg calls where the second arg is a string literal
if (args.length !== 2) return
const typeArg = args[1]
if (typeArg.type !== 'StringLiteral' && typeArg.type !== 'Literal') return
const legacyType = typeArg.value
const newType = convertLegacyToastType(legacyType)
// Replace the second argument with an options object: {type: 'newType'}
args[1] = j.objectExpression([
j.property('init', j.identifier('type'), j.stringLiteral(newType)),
])
})
return root.toSource()
}
+184 -13
View File
@@ -24,13 +24,15 @@ yarn android # Run on Android
yarn 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
# Internationalization
yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it)
yarn intl:compile # Compile translations for runtime
# 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)
# Build
yarn build-web # Build web version
@@ -44,6 +46,7 @@ src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── features/ # Macro-features that bridge components/screens
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
@@ -58,6 +61,121 @@ src/
└── Navigation.tsx # Main navigation configuration
```
### Project Structure in Depth
When building new things, follow these guidelines for where to put code.
#### Components vs Screens vs Features
**Components** are reusable UI elements that are not full screens. Should be
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
these in `/components` if they are shared across screens.
**Screens** are full-page components that represent a route in the app. They
often contain multiple components and handle layout for a page. New screens
should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code.
For complex screens that have specific components or data needs that _are not
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`.
**Features** are higher-level modules that may include context, data fetching,
components, and utilities related to a specific feature e.g.
`/features/liveNow`. They don't neatly fit into components or screens and often
span multiple screens. This is an optional pattern for organizing complex
features.
#### Legacy Directories
For the most part, avoid writing new files into the `/view` directory and
subdirectories. This is the older pattern for organizing screens and components,
and it has become a bit disorganized over time. New development should go into
`/screens`, `/components`, and `/features`.
#### State
The `/state` directory is where we've historically put all our data fetching and
state management logic. This is perfectly fine, but for new features, consider
organizing state logic closer to the components that use it, either within a
feature directory or co-located with a screen. The key is to keep related code
together and avoid having "god files" with too much unrelated logic.
#### Lib
The `/lib` directory is for utilities and helpers that don't fit into other
categories. This can include things like API clients, formatting functions,
constants, and other shared logic.
#### Top Level Directories
Avoid writing new top-level subdirectories within `/src`. We've done this for a
few things in the past that, but we have stronger patterns now. Examples:
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
better classified within `/features`. We will probably migrate these things
eventually.
### File and Directory Naming Conventions
Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it
should be broken down into multiple files. For "macro" component cases, or
things that live in `/features` or `/screens`, we often follow a pattern of
having an `index.tsx` for the main component, and then co-locating related
components, hooks, and utilities in the same directory. For example:
```
src
├── screens/
│ ├── ProfileScreen/
│ │ ├── index.tsx # Main screen component
│ │ ├── components/ # Sub-components used only by this screen
```
Similar patterns can be found in `/features` and `/components`. The idea here is
to keep related code together and make it easier to navigate.
You should ask yourself: if someone new was looking for the code related to this
feature or screen, where would they expect to find it? Organizing code in a way
that matches developer expectations can make the codebase much more
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
easier to understand than having it scattered across multiple directories.
No need to go overboard with this. If a component or feature fits into a single
file, there's no reason to have a `/Component/index.tsx` file when it could just
be `/Component.tsx`. Use your judgment based on the complexity and amount of
related code.
#### Platform Specific Files
We have conflicting patterns in the app for this. The preferred approach is to
group platform-specific files into a directory as much as possible. For example,
rather than having `Component.tsx`, `Component.web.tsx`, and
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Documentation and Tests Within Features
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
itself well to this, since the `index.tsx` can be the main component file, and
the `README.md` can provide documentation for the whole feature. This is
optional, but can be a nice way to keep documentation close to the code it
describes.
Similarly, if there are tests that are specific to a component or feature, it
can be helpful to include them in the same directory, either as
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
related to the component or feature in one place and makes it easier to find and
maintain tests.
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
@@ -269,7 +387,8 @@ import * as TextField from '#/components/forms/TextField'
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, Trans, plural} from '@lingui/macro'
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
@@ -299,8 +418,9 @@ function MyComponent() {
**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 for runtime (required after changes)
yarn intl:compile # Compile translations for runtime
```
## State Management
@@ -311,16 +431,30 @@ yarn intl:compile # Compile for runtime (required after changes)
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
// Query key pattern
const RQKEY_ROOT = 'profile'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
import {createQueryKey} from '#/state/queries/util'
// Query hook
/*
* Query key name should match the query hook name for consistency
*/
const profileQueryKeyRoot = 'profile'
/*
* Use object params and createQueryKey helper for better readability and to
* avoid bugs with parameter order or types.
*/
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args)
/*
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
@@ -330,8 +464,12 @@ export function useProfileQuery({did}: {did: string}) {
})
}
// Mutation hook
export function useUpdateProfile() {
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
@@ -339,7 +477,9 @@ export function useUpdateProfile() {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: (error) => {
if (isNetworkError(error)) {
@@ -353,6 +493,24 @@ export function useUpdateProfile() {
}
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
@@ -371,7 +529,7 @@ export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: ['drafts'],
queryKey: createQueryKey('drafts'),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
@@ -384,6 +542,19 @@ export function useDraftsQuery() {
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
**Persisted Queries**
To persist query data across app restarts, `createQueryKey` supports a third
parameter called `options`, which has a `persistedVersion` property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
```tsx
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
```
### Preferences (React Context)
```tsx
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS build-env
FROM golang:1.26-bookworm AS build-env
WORKDIR /usr/src/social-app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS build-env
FROM golang:1.26-bookworm AS build-env
WORKDIR /usr/src/social-app
+4 -3
View File
@@ -39,7 +39,6 @@ appId: xyz.blueskyweb.app
id: "editListNameInput"
- eraseText
- inputText: "Bad Ppl"
- hideKeyboard
- tapOn:
id: "editListDescriptionInput"
- eraseText
@@ -92,7 +91,8 @@ appId: xyz.blueskyweb.app
- tapOn: "Add user to list"
- swipe:
direction: DOWN
- assertVisible: "View Bob's profile"
- assertVisible:
id: "profileCard-bob.test-link"
- tapOn: "Posts"
- assertVisible:
@@ -124,7 +124,8 @@ appId: xyz.blueskyweb.app
- tapOn: "Good Ppl"
- tapOn: "People"
- assertVisible: "View Bob's profile"
- assertVisible:
id: "profileCard-bob.test-link"
- tapOn:
point: "90%,43%"
- tapOn:
+12 -6
View File
@@ -35,9 +35,12 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
id: "feed-timeline-moveDown"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
@@ -55,9 +58,12 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
id: "feed-feed-moveDown"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
+5
View File
@@ -15,6 +15,11 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "customServerTextInput"
- inputText: "http://localhost:3000"
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
- tapOn: "Done"
- tapOn:
id: "loginUsernameInput"
@@ -26,6 +26,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "report:details"
- inputText: "This is a test report"
- hideKeyboard
- tapOn:
id: "report:submit"
- assertNotVisible:
-529
View File
@@ -1,529 +0,0 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from '../jest/test-pds'
async function main() {
let server: TestPDS
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(
await server.mocker.createPost(user, `Post ${i}`),
)
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account',
'muted-by-list-acc',
'blocking-account',
'blockedby-account',
'mutual-block-acc',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount(
'not-a-real-label',
'unknown-account',
)
await server.mocker.labelProfile(
'not-a-real-label',
'unknown-profile',
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createPost('unknown-posts', 'unknown post'),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createQuotePost(
'unknown-posts',
'unknown quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createReply(
'unknown-posts',
'unknown reply',
anchorPost,
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createPost(
'no-promote-posts',
'no-promote post',
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'muted quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'muted reply',
anchorPost,
)
const list = await server.mocker.createMuteList(
'alice',
'Muted Users',
)
await server.mocker.addToMuteList(
'alice',
list,
server.mocker.users['muted-by-list-acc'].did,
)
await server.mocker.createPost('muted-by-list-acc', 'muted post')
await server.mocker.createQuotePost(
'muted-by-list-acc',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-by-list-acc',
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost(
'mutual-block-acc',
'mutual-block post',
)
await server.mocker.createQuotePost(
'mutual-block-acc',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-acc',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-acc'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-acc'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-acc'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
return res
.writeHead(200, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
pdsUrl: server.pdsUrl,
appviewDid: server.appviewDid,
}),
)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
}
main()
+23 -9
View File
@@ -3,15 +3,29 @@ appId: xyz.blueskyweb.app
- launchApp:
appId: "xyz.blueskyweb.app"
clearState: true
- waitForAnimationToEnd
- tapOn: "http://localhost:8081"
- waitForAnimationToEnd
- extendedWaitUntil:
visible: "Continue"
- swipe:
from: "Bluesky"
direction: DOWN
duration: 100
arguments:
"-EXDevMenuIsOnboardingFinished": true
- runFlow:
when:
platform: iOS
commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- runFlow:
when:
visible: 'Open in "Bluesky"'
commands:
- tapOn: Open
- runFlow:
when:
platform: Android
commands:
- tapOn: 'http://localhost:8081'
- runFlow:
label: "Dismiss Expo dev menu"
when:
visible: "Continue"
commands:
- back
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
-6
View File
@@ -1,6 +0,0 @@
export default {
requestPermission: jest.fn(),
onForegroundEvent: jest.fn(),
setBadgeCount: jest.fn(),
displayNotification: jest.fn(),
}
@@ -1,9 +0,0 @@
export const CameraRoll = {
getPhotos: jest.fn().mockResolvedValue({
edges: [
{node: {image: {uri: 'path/to/image1.jpg'}}},
{node: {image: {uri: 'path/to/image2.jpg'}}},
{node: {image: {uri: 'path/to/image3.jpg'}}},
],
}),
}
@@ -1,4 +0,0 @@
export default {
configure: jest.fn().mockResolvedValue(0),
finish: jest.fn(),
}
-1
View File
@@ -1 +0,0 @@
export default {}
-10
View File
@@ -1,10 +0,0 @@
jest.mock('rn-fetch-blob', () => {
return {
__esModule: true,
default: {
fs: {
unlink: jest.fn(),
},
},
}
})
-2
View File
@@ -1,2 +0,0 @@
export const DropdownMenu = jest.fn().mockImplementation(() => {})
export const create = jest.fn().mockImplementation(() => {})
+30
View File
@@ -1,4 +1,5 @@
import {RichText} from '@atproto/api'
import {i18n} from '@lingui/core'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {
@@ -6,6 +7,7 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
@@ -202,6 +204,9 @@ describe('enforceLen', () => {
})
describe('cleanError', () => {
// cleanError uses lingui
i18n.loadAndActivate({locale: 'en', messages})
const inputs = [
'TypeError: Network request failed',
'Error: Aborted',
@@ -327,6 +332,7 @@ describe('shortenLinks', () => {
expect(outputRT.text).toEqual(outputs[i][0])
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
for (let j = 0; j < outputs[i][1].length; j++) {
// @ts-expect-error whatever
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
}
}
@@ -437,6 +443,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
'https://maxblansjaar.bandcamp.com/album/false-comforts',
'https://grmnygrmny.bandcamp.com/track/fluid',
'https://sufjanstevens.bandcamp.com/',
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
]
const outputs = [
@@ -815,6 +828,23 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
{
type: 'bandcamp_album',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
{
type: 'bandcamp_track',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
+23 -12
View File
@@ -35,6 +35,13 @@ module.exports = function (_config) {
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const IOS_ICON_FILE =
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: IS_TESTFLIGHT
? './assets/app-icons/ios_icon_testflight.icon'
: './assets/app-icons/ios_icon_default.icon'
return {
expo: {
version: VERSION,
@@ -47,7 +54,7 @@ module.exports = function (_config) {
},
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
primaryColor: '#1083fe',
primaryColor: '#006AFF',
newArchEnabled: false,
ios: {
supportsTablet: false,
@@ -55,11 +62,9 @@ module.exports = function (_config) {
config: {
usesNonExemptEncryption: false,
},
icon:
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: './assets/app-icons/ios_icon_default.icon',
icon: IOS_ICON_FILE,
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
@@ -112,13 +117,13 @@ module.exports = function (_config) {
'zh-Hans',
'zh-Hant',
],
UIDesignRequiresCompatibility: true,
},
associatedDomains: ASSOCIATED_DOMAINS,
entitlements: {
'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.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -255,9 +260,16 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 35,
compileSdkVersion: 36,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
buildReactNativeFromSource: IS_PRODUCTION,
@@ -285,7 +297,6 @@ module.exports = function (_config) {
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidManifestIntentQueriesPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidDayNightThemePlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js',
@@ -312,22 +323,22 @@ module.exports = function (_config) {
{
ios: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
dark: {
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

@@ -0,0 +1,113 @@
{
"fill" : {
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
},
"groups" : [
{
"blend-mode-specializations" : [
{
"value" : "overlay"
},
{
"appearance" : "dark",
"value" : "screen"
},
{
"appearance" : "tinted",
"value" : "screen"
}
],
"blur-material-specializations" : [
{
"value" : 0.5
},
{
"appearance" : "dark",
"value" : 0.5
},
{
"appearance" : "tinted",
"value" : null
}
],
"hidden" : false,
"layers" : [
{
"image-name" : "TestFlight notice.png",
"name" : "TestFlight notice"
}
],
"lighting" : "individual",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
0,
350
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : false
}
],
"translucency-specializations" : [
{
"value" : {
"enabled" : true,
"value" : 0.5
}
},
{
"appearance" : "dark",
"value" : {
"enabled" : true,
"value" : 0.5
}
},
{
"appearance" : "tinted",
"value" : {
"enabled" : true,
"value" : 0.5
}
}
]
},
{
"layers" : [
{
"fill" : "none",
"glass" : false,
"image-name" : "iOS transparent.png",
"name" : "iOS transparent"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+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="M12 0a2 2 0 0 1 1 3.73V5h4.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.31 1.31C22 7.28 22 8.12 22 9.8v.25a2.501 2.501 0 0 1 0 4.9V15c0 2.8 0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 23 16.8 23 14 23h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 19.2 2 17.8 2 15v-.05a2.5 2.5 0 0 1 0-4.9V9.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.31-1.31C4.28 5 5.12 5 6.8 5H11V3.73A2 2 0 0 1 12 0M8 10a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 621 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="M12 2a1 1 0 0 1 1 1v2h3.2l1.113.005c.975.015 1.568.077 2.05.322a3 3 0 0 1 1.31 1.31C21 7.28 21 8.12 21 9.8v.287a1.498 1.498 0 0 1-.005 2.827c-.006 2.204-.058 3.41-.54 4.356l-.093.174a5 5 0 0 1-2.092 2.011l-.205.096c-.766.33-1.72.417-3.21.44L13 20h-2l-1.854-.009c-1.49-.023-2.445-.11-3.211-.44l-.205-.096a5 5 0 0 1-2.185-2.185c-.409-.803-.51-1.79-.536-3.415l-.005-.94A1.498 1.498 0 0 1 3 10.086V9.8c0-1.575 0-2.412.27-3.04l.057-.122a3 3 0 0 1 1.105-1.196l.206-.115C5.279 5 6.12 5 7.8 5H11V3a1 1 0 0 1 1-1M7.8 7c-.873 0-1.408.002-1.808.034a3 3 0 0 0-.367.051l-.063.018-.016.006a1 1 0 0 0-.437.437l-.006.016-.018.063a3 3 0 0 0-.05.367C5.001 8.392 5 8.927 5 9.8V12c0 1.433.002 2.388.062 3.121.058.71.16 1.036.265 1.241a3 3 0 0 0 1.31 1.31c.207.106.532.209 1.242.267.733.06 1.688.061 3.121.061h2c1.433 0 2.388-.002 3.121-.061.71-.058 1.036-.161 1.241-.266a3 3 0 0 0 1.31-1.31c.106-.206.209-.532.267-1.242.06-.733.061-1.688.061-3.121V9.8c0-.873-.002-1.408-.034-1.808a2.5 2.5 0 0 0-.051-.367l-.017-.063-.007-.016a1 1 0 0 0-.437-.437l-.015-.006-.064-.018a3 3 0 0 0-.367-.05C17.608 7.001 17.073 7 16.2 7zM9 10a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m6 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>

After

Width:  |  Height:  |  Size: 303 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="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.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"/></svg>

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = function (api) {
],
],
plugins: [
'macros',
'@lingui/babel-plugin-lingui-macro',
['babel-plugin-react-compiler', {target: '19'}],
[
'module:react-native-dotenv',
+9 -9
View File
@@ -1,10 +1,10 @@
<svg width="105" height="32" viewBox="0 0 105 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.7901 9.77492C21.7947 11.2889 23.9508 14.3587 24.7425 16.0059C25.5342 14.3587 27.6903 11.2889 29.6949 9.77492C31.1413 8.68251 33.485 7.83725 33.485 10.5269C33.485 11.064 33.1789 15.0393 32.9993 15.6848C32.3752 17.9285 30.1009 18.5008 28.0778 18.1544C31.6141 18.7598 32.5136 20.7653 30.5709 22.7708C26.1996 27.2831 24.7425 20.3569 24.7425 20.3569C24.7425 20.3569 23.2854 27.2831 18.9142 22.7708C16.9714 20.7653 17.871 18.7598 21.4072 18.1544C19.3841 18.5008 17.1099 17.9285 16.4857 15.6848C16.3061 15.0393 16 11.064 16 10.5269C16 7.83725 18.3437 8.68251 19.7901 9.77492Z" fill="white"/>
<path d="M44.3863 16.3646C45.5901 16.7932 46.2292 17.7837 46.2292 18.8924C46.2292 20.7698 44.9659 21.908 42.5434 21.908H37.5942V11.3828H42.3799C44.6835 11.3828 45.7982 12.5506 45.7982 14.1028C45.7982 15.1376 45.3226 15.8915 44.3863 16.3646ZM42.2313 13.0237H39.6006V15.7732H42.2313C43.2568 15.7732 43.8067 15.2411 43.8067 14.3541C43.8067 13.5411 43.2419 13.0237 42.2313 13.0237ZM39.6006 20.2524H42.4393C43.5689 20.2524 44.1782 19.735 44.1782 18.8037C44.1782 17.828 43.5986 17.3402 42.4393 17.3402H39.6006V20.2524Z" fill="white"/>
<path d="M49.0111 21.908H47.1385V11.3828H49.0111V21.908Z" fill="white"/>
<path d="M55.2202 18.5524V14.2802H57.0929V21.908H55.2797V20.7993C54.7 21.6567 53.8975 22.0854 52.872 22.0854C51.252 22.0854 50.1968 21.1098 50.1968 19.3359V14.2802H52.0694V19.0254C52.0694 19.9863 52.545 20.4741 53.5111 20.4741C54.4177 20.4741 55.2202 19.8089 55.2202 18.5524Z" fill="white"/>
<path d="M65.6167 18.2272V18.6706H59.8799C60.0137 19.9863 60.7419 20.6367 61.8714 20.6367C62.7334 20.6367 63.3131 20.2672 63.6252 19.5428H65.4235C65.0222 21.095 63.6846 22.0854 61.8566 22.0854C60.7122 22.0854 59.7907 21.7159 59.0922 20.9915C58.3937 20.2672 58.037 19.3063 58.037 18.0941C58.037 16.8967 58.3788 15.9359 59.0773 15.1967C59.7759 14.4724 60.6825 14.1028 61.8269 14.1028C62.9861 14.1028 63.9076 14.4872 64.5912 15.2411C65.2749 15.995 65.6167 17.0002 65.6167 18.2272ZM61.812 15.5515C60.7865 15.5515 60.0731 16.1428 59.8948 17.3698H63.7441C63.5806 16.2611 62.8969 15.5515 61.812 15.5515Z" fill="white"/>
<path d="M69.7673 22.115C67.5379 22.115 66.3638 21.2428 66.2598 19.4837H68.0878C68.1918 20.4298 68.6674 20.7846 69.797 20.7846C70.8076 20.7846 71.3129 20.4741 71.3129 19.868C71.3129 19.3211 70.9562 19.055 69.8118 18.8628L68.935 18.715C67.2555 18.4341 66.4232 17.6654 66.4232 16.4089C66.4232 14.975 67.5676 14.1028 69.6038 14.1028C71.7885 14.1028 72.9181 14.9602 72.9924 16.6898H71.2238C71.1792 15.7585 70.6293 15.4332 69.6038 15.4332C68.712 15.4332 68.2662 15.7289 68.2662 16.3202C68.2662 16.8524 68.6526 17.0889 69.5443 17.2515L70.5104 17.3993C72.3681 17.7393 73.1707 18.4193 73.1707 19.7202C73.1707 21.2576 71.9223 22.115 69.7673 22.115Z" fill="white"/>
<path d="M81.3899 21.908H79.2497L77.0204 18.3602L75.8611 19.5132V21.908H74.0182V11.3828H75.8611V17.4437L78.9822 14.2802H81.2116L78.3134 17.1628L81.3899 21.908Z" fill="white"/>
<path d="M86.2805 16.2019L86.9047 14.2802H88.8665L85.909 22.5289C85.5968 23.3715 85.2104 23.9776 84.72 24.3176C84.2295 24.6576 83.531 24.8202 82.6095 24.8202C82.2974 24.8202 82.0299 24.8054 81.7921 24.7759V23.3124H82.5055C83.3526 23.3124 83.7688 22.795 83.7688 22.0854C83.7688 21.7306 83.6499 21.2132 83.4121 20.548L81.1827 14.2802H83.204L83.8282 16.1872C84.289 17.6211 84.6902 19.0402 85.0469 20.4446C85.3739 19.2324 85.7901 17.8132 86.2805 16.2019Z" fill="white"/>
<svg width="120" height="28" viewBox="0 0 120 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.23031 1.75921C9.52558 4.31406 13.0698 9.49434 14.3713 12.2741C15.6727 9.49434 19.2169 4.31406 22.5122 1.75921C24.8899 -0.0842317 28.7425 -1.5106 28.7425 3.02818C28.7425 3.93462 28.2393 10.6429 27.9441 11.7321C26.9181 15.5183 23.1796 16.4841 19.8539 15.8995C25.667 16.9212 27.1457 20.3055 23.9521 23.6897C16.7665 31.3043 14.3713 19.6163 14.3713 19.6163C14.3713 19.6163 11.976 31.3043 4.79042 23.6897C1.59681 20.3055 3.07554 16.9212 8.88858 15.8995C5.56292 16.4841 1.82441 15.5183 0.798403 11.7321C0.503259 10.6429 0 3.93462 0 3.02818C0 -1.5106 3.85263 -0.0842317 6.23031 1.75921Z" fill="#006AFF"/>
<path d="M46.662 12.8778C48.641 13.6012 49.6915 15.2726 49.6915 17.1435C49.6915 20.3116 47.6149 22.2324 43.6326 22.2324H35.497V4.47113H43.3638C47.1507 4.47113 48.983 6.44184 48.983 9.06113C48.983 10.8073 48.2012 12.0796 46.662 12.8778ZM43.1195 7.2401H38.7952V11.88H43.1195C44.8053 11.88 45.7092 10.9819 45.7092 9.48521C45.7092 8.1132 44.7808 7.2401 43.1195 7.2401ZM38.7952 19.4385H43.4616C45.3183 19.4385 46.32 18.5654 46.32 16.9938C46.32 15.3474 45.3672 14.5242 43.4616 14.5242H38.7952V19.4385Z" fill="#006AFF"/>
<path d="M54.2645 22.2324H51.1862V4.47113H54.2645V22.2324Z" fill="#006AFF"/>
<path d="M64.4712 16.5698V9.36048H67.5495V22.2324H64.5689V20.3615C63.6161 21.8084 62.2968 22.5318 60.6111 22.5318C57.9481 22.5318 56.2135 20.8854 56.2135 17.8919V9.36048H59.2918V17.368C59.2918 18.9895 60.0736 19.8127 61.6616 19.8127C63.1519 19.8127 64.4712 18.6902 64.4712 16.5698Z" fill="#006AFF"/>
<path d="M81.5614 16.021V16.7693H72.131C72.3508 18.9895 73.548 20.0871 75.4047 20.0871C76.8217 20.0871 77.7746 19.4635 78.2876 18.2411H81.2438C80.5841 20.8604 78.3853 22.5318 75.3803 22.5318C73.4991 22.5318 71.9844 21.9081 70.8361 20.6858C69.6878 19.4635 69.1015 17.842 69.1015 15.7965C69.1015 13.7759 69.6634 12.1544 70.8117 10.9071C71.9599 9.68477 73.4502 9.06113 75.3314 9.06113C77.2371 9.06113 78.7518 9.70972 79.8756 10.9819C80.9995 12.2542 81.5614 13.9505 81.5614 16.021ZM75.307 11.5058C73.6213 11.5058 72.4486 12.5036 72.1554 14.5741H78.4831C78.2143 12.7032 77.0905 11.5058 75.307 11.5058Z" fill="#006AFF"/>
<path d="M88.3842 22.5817C84.7195 22.5817 82.7894 21.1099 82.6184 18.1413H85.6234C85.7944 19.7379 86.5762 20.3366 88.433 20.3366C90.0943 20.3366 90.925 19.8127 90.925 18.7899C90.925 17.8669 90.3386 17.4179 88.4574 17.0936L87.016 16.8442C84.2553 16.3702 82.8871 15.073 82.8871 12.9527C82.8871 10.5329 84.7683 9.06113 88.1154 9.06113C91.7068 9.06113 93.5636 10.508 93.6857 13.4266H90.7784C90.7051 11.855 89.8012 11.3062 88.1154 11.3062C86.6495 11.3062 85.9166 11.8052 85.9166 12.803C85.9166 13.701 86.5518 14.1002 88.0177 14.3746L89.6057 14.624C92.6596 15.1978 93.9789 16.3453 93.9789 18.5405C93.9789 21.1348 91.9267 22.5817 88.3842 22.5817Z" fill="#006AFF"/>
<path d="M107.49 22.2324H103.972L100.307 16.2455L98.4015 18.1912V22.2324H95.372V4.47113H98.4015V14.6988L103.532 9.36048H107.197L102.433 14.2249L107.49 22.2324Z" fill="#006AFF"/>
<path d="M115.529 12.6034L116.555 9.36048H119.78L114.918 23.2802C114.405 24.7021 113.77 25.7248 112.964 26.2986C112.158 26.8723 111.009 27.1467 109.495 27.1467C108.982 27.1467 108.542 27.1218 108.151 27.0719V24.6023H109.324C110.716 24.6023 111.4 23.7292 111.4 22.5318C111.4 21.9331 111.205 21.06 110.814 19.9374L107.149 9.36048H110.472L111.498 12.5785C112.255 14.9982 112.915 17.393 113.501 19.7628C114.039 17.7173 114.723 15.3225 115.529 12.6034Z" fill="#006AFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"/></svg>

After

Width:  |  Height:  |  Size: 182 B

+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'
+3 -5
View File
@@ -20,7 +20,7 @@ export function Container({
if (!entry) return
let {height} = entry.contentRect
height += 2 // border top and bottom
height += 4 // border-2 = 2px top + 2px bottom
if (height !== prevHeight.current) {
prevHeight.current = height
window.parent.postMessage(
@@ -37,7 +37,7 @@ export function Container({
return (
<div
ref={ref}
className="w-full bg-brand text-black dark:bg-brand relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[20px] cursor-pointer hover:bg-opacity-90"
className="w-full border-2 border-brand text-black relative transition-colors max-w-[600px] min-w-[300px] flex items-center dark:text-slate-200 rounded-[32px] overflow-hidden cursor-pointer"
onClick={() => {
if (ref.current && href) {
// forwardRef requires preact/compat - let's keep it simple
@@ -49,9 +49,7 @@ export function Container({
}
}}>
{href && <Link href={href} />}
<div className="flex-1 px-[6px] pt-[6px] pb-2.5 max-w-full">
{children}
</div>
<div className="flex-1 max-w-full">{children}</div>
</div>
)
}
+36 -9
View File
@@ -14,8 +14,9 @@ import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import playIcon from '../../assets/play_filled_corner0_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {Globe} from '../icons/Globe'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import * as bsky from '../types/bsky'
import {getRkey} from '../util/rkey'
@@ -93,7 +94,7 @@ export function Embed({
/>
</div>
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
<p className="text-sm shrink-0 font-semibold max-w-[70%] truncate">
{record.author.displayName?.trim() || record.author.handle}
</p>
{verification.isVerified && (
@@ -103,7 +104,7 @@ export function Embed({
size={12}
/>
)}
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
<p className="text-sm text-textLight dark:text-textDimmed min-w-0 truncate ml-1">
@{record.author.handle}
</p>
</div>
@@ -334,13 +335,18 @@ function ExternalEmbed({
/>
)}
<div className="py-3 px-4">
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-1">
{toNiceDomain(content.external.uri)}
<p className="font-semibold leading-tight line-clamp-3">
{content.external.title}
</p>
<p className="font-semibold line-clamp-3">{content.external.title}</p>
<p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
<p className="text-sm leading-snug text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
{content.external.description}
</p>
<div className="flex flex-row items-center gap-1 border-t dark:border-slate-600 mt-1 pt-1.5">
<Globe size={12} className="text-textLight dark:text-textDimmed" />
<p className="text-sm leading-none text-textLight dark:text-textDimmed line-clamp-1">
{toNiceDomain(content.external.uri)}
</p>
</div>
</div>
</Link>
)
@@ -374,7 +380,7 @@ function GenericWithImageEmbed({
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
)}
<div className="flex-1">
<p className="font-bold text-sm">{title}</p>
<p className="font-semibold text-sm">{title}</p>
<p className="text-textLight dark:text-textDimmed text-sm">
{subtitle}
</p>
@@ -389,7 +395,6 @@ function GenericWithImageEmbed({
)
}
// just the thumbnail and a play button
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
let aspectRatio = 1
@@ -398,6 +403,28 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
}
const supportsHls = useMemo(() => {
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
}, [])
if (supportsHls) {
return (
<video
src={content.playlist}
poster={content.thumbnail}
controls
playsinline
preload="metadata"
loading="lazy"
aria-label={content.alt || undefined}
onClickCapture={evt => evt.stopPropagation()}
className="w-full rounded-xl bg-black"
style={{aspectRatio: `${aspectRatio} / 1`}}
/>
)
}
return (
<div
className="w-full overflow-hidden rounded-xl aspect-square relative"
+69 -63
View File
@@ -10,6 +10,7 @@ import logo from '../../assets/logo_full_name.svg'
import {Like as LikeIcon} from '../icons/Like'
import {Reply as ReplyIcon} from '../icons/Reply'
import {Repost as RepostIcon} from '../icons/Repost'
import {Robot as RobotIcon} from '../icons/Robot'
import {CONTENT_LABELS} from '../labels'
import * as bsky from '../types/bsky'
import {niceDate} from '../util/nice-date'
@@ -43,13 +44,16 @@ export function Post({thread}: Props) {
}
const verification = getVerificationState({profile: post.author})
const isBot = post.author.labels?.some(
l => l.val === 'bot' && l.src === post.author.did,
)
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
return (
<Container href={href}>
<div
className="flex-1 flex-col flex gap-2 bg-neutral-50 dark:bg-black dark:hover:bg-slate-900 hover:bg-blue-50 rounded-[14px] p-4"
className="flex-1 flex-col flex gap-4 bg-white dark:bg-black hover:bg-brandHover dark:hover:bg-brandHoverDark rounded-[30px] p-5"
lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full ">
<Link
@@ -66,7 +70,7 @@ export function Post({thread}: Props) {
<div className="flex flex-1 items-center">
<Link
href={`/profile/${post.author.did}`}
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
className="block font-semibold text-[15px] min-[400px]:text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
{post.author.displayName?.trim() || post.author.handle}
</Link>
{verification.isVerified && (
@@ -76,73 +80,75 @@ export function Post({thread}: Props) {
size={15}
/>
)}
{isBot && (
<RobotIcon
className="pl-[3px] mt-px shrink-0 text-slate-500 dark:text-slate-400"
size={15}
/>
)}
</div>
<div className="flex items-center gap-1 text-[13px] min-[400px]:text-[15px] min-w-0">
<Link
href={`/profile/${post.author.did}`}
className="text-textNeutral hover:underline line-clamp-1">
@{post.author.handle}
</Link>
<span className="text-textNeutral shrink-0">·</span>
<Link
href={`/profile/${post.author.did}`}
className="text-brand hover:underline shrink-0">
Follow
</Link>
</div>
<Link
href={`/profile/${post.author.did}`}
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
@{post.author.handle}
</Link>
</div>
</div>
<PostContent record={record} />
<Embed content={post.embed} labels={post.labels} />
<div className="flex items-center justify-between w-full pt-2.5 text-sm">
<div className="flex items-center gap-3 text-sm cursor-pointer">
{!!post.likeCount && (
<div className="flex items-center gap-1 cursor-pointer group">
<LikeIcon
width={20}
height={20}
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
/>
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
{prettyNumber(post.likeCount)}
</p>
</div>
)}
{!!post.replyCount && (
<div className="flex items-center gap-1 cursor-pointer group">
<ReplyIcon
width={20}
height={20}
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
/>
<p className="font-medium text-slate-600 text-neutral-600 dark:text-neutral-300 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors dark:text-slate-400">
{prettyNumber(post.replyCount)}
</p>
</div>
)}
{!!post.repostCount && (
<div className="flex items-center gap-1 cursor-pointer group">
<RepostIcon
width={20}
height={20}
className="text-slate-600 dark:text-slate-400 group-hover:text-neutral-800 dark:group-hover:text-white transition-colors"
/>
<p className="font-medium text-slate-600 dark:text-slate-400 mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
{prettyNumber(post.repostCount)}
</p>
</div>
)}
<div className="flex items-end justify-between w-full">
<div className="flex flex-col min-[400px]:gap-0.5">
<div className="flex items-center gap-3 text-sm cursor-pointer ml-[-2px]">
{!!post.likeCount && (
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
<LikeIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
{prettyNumber(post.likeCount)}
</p>
</div>
)}
{!!post.replyCount && (
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
<ReplyIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
{prettyNumber(post.replyCount)}
</p>
</div>
)}
{!!post.repostCount && (
<div className="flex items-center gap-0.5 min-[400px]:gap-1 cursor-pointer group">
<RepostIcon className="w-5 h-5 min-[400px]:w-[22px] min-[400px]:h-[22px] text-textLight dark:text-textDimmed group-hover:text-neutral-800 dark:group-hover:text-white transition-colors" />
<p className="text-[11px] min-[400px]:text-[15px] font-semibold text-textLight dark:text-textDimmed mb-px group-hover:text-neutral-800 dark:group-hover:text-white transition-colors">
{prettyNumber(post.repostCount)}
</p>
</div>
)}
</div>
<Link href={href}>
<time
datetime={new Date(post.indexedAt).toISOString()}
className="text-[11px] min-[400px]:text-[15px] text-textNeutral hover:underline">
{niceDate(post.indexedAt)}
</time>
</Link>
</div>
<Link href={href}>
<time
datetime={new Date(post.indexedAt).toISOString()}
className="text-slate-500 dark:text-textDimmed text-sm hover:underline dark:text-slate-500">
{niceDate(post.indexedAt)}
</time>
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0">
<img src={logo} className="h-5 min-[400px]:h-7" />
</Link>
</div>
</div>
<div className="flex items-center justify-end pt-2">
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0">
<img src={logo} className="h-8" />
</Link>
</div>
</Container>
)
}
@@ -167,7 +173,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
<Link
key={counter}
href={segment.link.uri}
className="text-blue-500 hover:underline"
className="text-brand hover:underline"
disableTracking={
!segment.link.uri.startsWith('https://bsky.app') &&
!segment.link.uri.startsWith('https://go.bsky.app')
@@ -183,7 +189,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
<Link
key={counter}
href={`/profile/${segment.mention.did}`}
className="text-blue-500 hover:underline">
className="text-brand hover:underline">
{segment.text}
</Link>,
)
@@ -195,7 +201,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
<Link
key={counter}
href={`/hashtag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
className="text-brand hover:underline">
{segment.text}
</Link>,
)
@@ -207,7 +213,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
}
return (
<p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
<p className="text-md min-[400px]:text-lg leading-snug min-[400px]:leading-snug break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
+24
View File
@@ -0,0 +1,24 @@
import {h} from 'preact'
export const Globe = ({
size = 14,
className,
}: {
size?: number
className?: string
}) => (
<svg
className={className}
width={size}
height={size}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.99 7.99 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z"
clip-rule="evenodd"
/>
</svg>
)
+22
View File
@@ -0,0 +1,22 @@
import {h} from 'preact'
export const Robot = ({
size = 14,
className,
}: {
size?: number
className?: string
}) => (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 0C13.1046 0 14 0.89543 14 2C14 2.73976 13.5971 3.3835 13 3.72949V5H17.2002C18.8802 5 19.7206 5.00018 20.3623 5.32715C20.9265 5.61472 21.3853 6.07347 21.6729 6.6377C21.9998 7.27941 22 8.11978 22 9.7998V10.0498C23.1411 10.2814 24 11.2905 24 12.5C24 13.7094 23.141 14.7175 22 14.9492V15C22 17.8 21.9999 19.2 21.4551 20.2695C20.9757 21.2103 20.2103 21.9757 19.2695 22.4551C18.2 22.9999 16.8 23 14 23H10C7.20005 23 5.79998 22.9999 4.73047 22.4551C3.78966 21.9757 3.02429 21.2103 2.54492 20.2695C2.00013 19.2 2 17.8 2 15V14.9492C0.858955 14.7175 0 13.7094 0 12.5C0 11.2905 0.85886 10.2814 2 10.0498V9.7998C2 8.11978 2.00018 7.27941 2.32715 6.6377C2.61472 6.07347 3.07347 5.61472 3.6377 5.32715C4.27941 5.00018 5.11978 5 6.7998 5H11V3.72949C10.4029 3.3835 10 2.73976 10 2C10 0.89543 10.8954 0 12 0ZM8 10C6.89543 10 6 10.8954 6 12V14C6 15.1046 6.89543 16 8 16C9.10457 16 10 15.1046 10 14V12C10 10.8954 9.10457 10 8 10ZM16 10C14.8954 10 14 10.8954 14 12V14C14 15.1046 14.8954 16 16 16C17.1046 16 18 15.1046 18 14V12C18 10.8954 17.1046 10 16 10Z"
fill="currentColor"
/>
</svg>
)
+6
View File
@@ -2,6 +2,12 @@
@tailwind components;
@tailwind utilities;
:root {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.break-word {
word-break: break-word;
}
+4 -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',
@@ -39,6 +39,7 @@ render(<LandingPage />, root)
function LandingPage() {
const [uri, setUri] = useState('')
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
@@ -118,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">
@@ -185,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">
+4 -4
View File
@@ -1,11 +1,11 @@
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
return `${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})} · ${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
+6 -3
View File
@@ -5,10 +5,13 @@ module.exports = {
theme: {
extend: {
colors: {
brand: 'rgb(10,122,255)',
brand: 'rgb(0,106,255)',
brandHover: 'rgb(245,249,255)',
brandHoverDark: 'rgb(17,24,34)',
brandLighten: 'rgb(32,139,254)',
textLight: 'rgb(66,87,108)',
textDimmed: 'rgb(174,187,201)',
textLight: 'rgb(63,82,104)',
textDimmed: 'rgb(164,179,197)',
textNeutral: 'rgb(102,123,153)',
dimmedBgLighten: 'rgb(30,41,54)',
dimmedBg: 'rgb(22,30,39)',
dimmedBgDarken: 'rgb(18,25,32)',
+3 -1
View File
@@ -4,7 +4,9 @@
"type": "module",
"main": "index.ts",
"scripts": {
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test": "npm run test:unit && npm 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"
},
"dependencies": {
+4
View File
@@ -15,6 +15,7 @@ export type ServiceConfig = {
safelinkPdsUrl?: string
safelinkAgentIdentifier?: string
safelinkAgentPass?: string
metricsApiHost?: string
}
export type DbConfig = {
@@ -45,6 +46,7 @@ export type Environment = {
safelinkPdsUrl?: string
safelinkAgentIdentifier?: string
safelinkAgentPass?: string
metricsApiHost?: string
}
export const readEnv = (): Environment => {
@@ -65,6 +67,7 @@ export const readEnv = (): Environment => {
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
metricsApiHost: envStr('LINK_METRICS_API_HOST'),
}
}
@@ -79,6 +82,7 @@ export const envToCfg = (env: Environment): Config => {
safelinkPdsUrl: env.safelinkPdsUrl,
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
safelinkAgentPass: env.safelinkAgentPass,
metricsApiHost: env.metricsApiHost,
}
if (!env.dbPostgresUrl) {
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
+5
View File
@@ -1,6 +1,7 @@
import {SafelinkClient} from './cache/safelinkClient.js'
import {type Config} from './config.js'
import Database from './db/index.js'
import {MetricsClient} from './metrics.js'
export type AppContextOptions = {
cfg: Config
@@ -12,6 +13,7 @@ export class AppContext {
db: Database
safelinkClient: SafelinkClient
abortController = new AbortController()
metrics: MetricsClient
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
@@ -20,6 +22,9 @@ export class AppContext {
cfg: this.opts.cfg.service,
db: this.opts.db,
})
this.metrics = new MetricsClient({
trackingEndpoint: this.opts.cfg.service.metricsApiHost,
})
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
+11 -1
View File
@@ -1,10 +1,20 @@
import escapeHTML from 'escape-html'
export function linkRedirectContents(link: string): string {
// Encode characters that could break out of the single-quoted URL in meta refresh.
// HTML entity escaping (&#39;) is insufficient because the browser decodes entities
// before the meta refresh parser processes the URL, allowing apostrophes to
// prematurely terminate the URL string.
//
// Example: "They're" with HTML escaping becomes "They&#39;re" in HTML, but after
// the browser decodes the content attribute, the meta refresh parser sees "They're"
// and interprets the apostrophe as the closing quote, truncating the URL to "They".
const safeLink = link.replace(/'/g, '%27')
return `
<html>
<head>
<meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" />
<meta http-equiv="refresh" content="0; URL='${escapeHTML(safeLink)}'" />
<meta
http-equiv="Cache-Control"
content="no-store, no-cache, must-revalidate, max-age=0" />
+2
View File
@@ -36,6 +36,7 @@ export class LinkService {
}
async start() {
this.ctx.metrics.start()
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
@@ -46,5 +47,6 @@ export class LinkService {
this.ctx.abortController.abort()
await this.terminator?.terminate()
await this.ctx.db.close()
this.ctx.metrics.stop()
}
}
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert'
import {afterEach, beforeEach, describe, it, mock} from 'node:test'
import {httpLogger} from './logger.js'
import {MetricsClient} from './metrics.js'
type TestEvents = {
click: {button: string}
view: {screen: string}
}
describe('MetricsClient', () => {
let fetchMock: ReturnType<typeof mock.fn>
let fetchRequests: {body: any}[]
let client: MetricsClient<TestEvents>
let loggerErrorMock: ReturnType<typeof mock.fn>
beforeEach(() => {
mock.timers.enable({apis: ['setInterval', 'setTimeout']})
fetchRequests = []
fetchMock = mock.fn(async (_url: any, options: any) => {
const body = JSON.parse(options.body)
fetchRequests.push({body})
return {ok: true, status: 200, text: async () => ''}
})
;(globalThis as any).fetch = fetchMock
loggerErrorMock = mock.fn()
httpLogger.error = loggerErrorMock as any
})
afterEach(() => {
client?.stop()
mock.timers.reset()
mock.restoreAll()
})
it('flushes events on interval', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
client.track('view', {screen: 'home'})
assert.strictEqual(fetchRequests.length, 0)
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 2)
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
assert.strictEqual(fetchRequests[0].body.events[1].event, 'view')
})
it('flushes when maxBatchSize is exceeded', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.maxBatchSize = 5
for (let i = 0; i < 5; i++) {
client.track('click', {button: `btn-${i}`})
}
assert.strictEqual(fetchRequests.length, 0)
client.track('click', {button: 'btn-trigger'})
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 6)
})
it('logs error on failed request', async () => {
fetchMock.mock.mockImplementation(async () => {
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
})
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 1)
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
const call = loggerErrorMock.mock.calls[0]
const arg = call.arguments[0] as {err: Error}
assert.ok(arg.err instanceof Error)
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
})
it('handles fetch text() error gracefully', async () => {
fetchMock.mock.mockImplementation(async () => {
return {
ok: false,
status: 500,
text: async () => {
throw new Error('Failed to read response')
},
}
})
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 1)
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
const call = loggerErrorMock.mock.calls[0]
const arg = call.arguments[0] as {err: Error}
assert.ok(arg.err instanceof Error)
assert.match(arg.err.message, /Unknown error/)
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
})
it('flushes when stop() is called', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
assert.strictEqual(fetchRequests.length, 0)
client.stop()
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 1)
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
})
it('does not send if trackingEndpoint is not configured', async () => {
client = new MetricsClient<TestEvents>({})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 0)
})
it('start() is idempotent', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
client.start()
client.start()
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchRequests.length, 1)
})
it('does not flush if queue is empty', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.start()
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 0)
})
})
function flush() {
return new Promise(r => setImmediate(r))
}
+141
View File
@@ -0,0 +1,141 @@
import crypto from 'node:crypto'
import {httpLogger} from './logger.js'
/**
* New metrics events should be added here
*/
type Events = {
redirect: {
link: string
whitelisted: 'unknown' | 'yes'
blocked: boolean
warned: boolean
utm_source?: string
utm_medium?: string
utm_campaign?: string
utm_content?: string
utm_term?: string
}
invalid_redirect: {
link: string
}
}
type Event<M extends Record<string, any>> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Record<string, any>
}
export type Config = {
trackingEndpoint?: string
}
/**
* This MetricsClient is duplicated from both `social-app` and `atproto`
* codebases.
*/
export class MetricsClient<M extends Record<string, any> = Events> {
maxBatchSize = 100
private disabled: boolean = false
private started: boolean = false
private queue: Event<M>[] = []
private flushInterval: NodeJS.Timeout | null = null
constructor(private config: Config) {
this.disabled = !config.trackingEndpoint
}
start() {
if (this.disabled) return
if (this.started) return
this.started = true
this.flushInterval = setInterval(() => {
this.flush()
}, 10_000)
}
stop() {
if (this.flushInterval) {
clearInterval(this.flushInterval)
this.flushInterval = null
}
this.flush()
}
track<E extends keyof M>(event: E, payload: M[E]) {
if (this.disabled) return
this.start()
/**
* deviceId is required for sharding events in Middleman. To avoid a hot
* shard, we generate a random anonymous IDs for this client.
*
* @see https://github.com/bluesky-social/tango/blob/d5819cde419d13e0d2cf837f4b30d48529d64060/middleman/handlers_tracking.go#L195
*/
const anonId = `anon-${crypto.randomUUID()}`
/**
* Event structure is like this to ensure compat with Middleman, which
* receives events like this from other codebases, including `social-app`.
*/
const e = {
source: 'blink',
time: Date.now(),
event,
payload,
metadata: {
base: {
deviceId: anonId,
sessionId: anonId,
},
session: {
did: undefined,
},
},
}
this.queue.push(e)
if (this.queue.length > this.maxBatchSize) {
this.flush()
}
}
flush() {
if (this.disabled) return
if (!this.queue.length) return
const events = this.queue.splice(0, this.queue.length)
this.sendBatch(events)
}
private async sendBatch(events: Event<M>[]) {
if (this.disabled || !this.config.trackingEndpoint) return
try {
const res = await fetch(this.config.trackingEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({events}),
keepalive: true,
})
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error')
httpLogger.error(
{err: new Error(`${res.status} Failed to fetch - ${errorText}`)},
'Failed to send metrics',
)
} else {
// Drain response body to allow connection reuse.
await res.text().catch(() => {})
}
} catch (err) {
httpLogger.error({err}, 'Failed to send metrics')
}
}
}
+19
View File
@@ -37,6 +37,7 @@ export default function (ctx: AppContext, app: Express) {
url.pathname === '/redirect') || // is a redirect loop
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
) {
ctx.metrics.track('invalid_redirect', {link})
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end()
@@ -48,6 +49,9 @@ export default function (ctx: AppContext, app: Express) {
res.type('html')
let html: string | undefined
let whitelisted: 'unknown' | 'yes' = 'unknown'
let blocked: boolean = false
let warned: boolean = false
if (ctx.cfg.service.safelinkEnabled) {
const rule = await ctx.safelinkClient.tryFindRule(link)
@@ -55,6 +59,7 @@ export default function (ctx: AppContext, app: Express) {
switch (rule.action) {
case 'whitelist':
redirectLogger.info({rule}, 'Whitelist rule matched')
whitelisted = 'yes'
break
case 'block':
html = linkWarningLayout(
@@ -66,6 +71,7 @@ export default function (ctx: AppContext, app: Express) {
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info({rule}, 'Block rule matched')
blocked = true
break
case 'warn':
html = linkWarningLayout(
@@ -77,6 +83,7 @@ export default function (ctx: AppContext, app: Express) {
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info({rule}, 'Warn rule matched')
warned = true
break
default:
redirectLogger.warn({rule}, 'Unknown rule matched')
@@ -89,6 +96,18 @@ export default function (ctx: AppContext, app: Express) {
html = linkRedirectContents(url.href)
}
ctx.metrics.track('redirect', {
link,
whitelisted,
blocked,
warned,
utm_source: req.query.utm_source?.toString(),
utm_medium: req.query.utm_medium?.toString(),
utm_campaign: req.query.utm_campaign?.toString(),
utm_content: req.query.utm_content?.toString(),
utm_term: req.query.utm_term?.toString(),
})
return res.end(html)
}),
)
+29 -9
View File
@@ -2,11 +2,9 @@ import assert from 'node:assert'
import {type AddressInfo} from 'node:net'
import {after, before, describe, it} from 'node:test'
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
describe('link service', async () => {
describe.skip('link service', async () => {
let linkService: LinkService
let baseUrl: string
before(async () => {
@@ -18,9 +16,9 @@ describe('link service', async () => {
dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
safelinkEnabled: true,
ozoneUrl: 'http://localhost:2583',
ozoneAgentHandle: 'mod-authority.test',
ozoneAgentPass: 'hunter2',
safelinkPdsUrl: 'http://localhost:2583',
safelinkAgentIdentifier: 'mod-authority.test',
safelinkAgentPass: 'hunter2',
})
const migrateDb = Database.postgres({
url: cfg.db.url,
@@ -33,6 +31,7 @@ describe('link service', async () => {
const {port} = linkService.server?.address() as AddressInfo
baseUrl = `http://localhost:${port}`
/*
// Ensure blocklist, whitelist, and safelink rules are set up
const now = new Date().toISOString()
linkService.ctx.cfg.eventCache.smartUpdate({
@@ -110,6 +109,7 @@ describe('link service', async () => {
comment:
'Could be quite the mistake to get into this addicting game, but we will warn instead of block',
})
*/
})
after(async () => {
await linkService?.destroy()
@@ -213,6 +213,7 @@ describe('link service', async () => {
)
})
/*
it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => {
// Retrieve the latest event after all updates
const result = linkService.ctx.cfg.eventCache.smartGet(
@@ -232,6 +233,7 @@ describe('link service', async () => {
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
})
*/
async function getRedirect(link: string): Promise<[number, string]> {
const url = new URL(link)
@@ -291,9 +293,10 @@ describe('link service no safelink', async () => {
dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
safelinkEnabled: false,
ozoneUrl: 'http://localhost:2583',
ozoneAgentHandle: 'mod-authority.test',
ozoneAgentPass: 'hunter2',
safelinkPdsUrl: 'http://localhost:2583',
safelinkAgentIdentifier: 'mod-authority.test',
safelinkAgentPass: 'hunter2',
metricsApiHost: 'http://localhost:2584',
})
const migrateDb = Database.postgres({
url: cfg.db.url,
@@ -357,4 +360,21 @@ describe('link service no safelink', async () => {
// No blocked-site div, always safe
assert.doesNotMatch(html, /"blocked-site"/)
})
it('normal redirect with query params', async () => {
const urlToRedirect = 'https://bsky.app/settings'
const url = new URL(`${baseUrl}/redirect`)
url.searchParams.set('u', urlToRedirect)
url.searchParams.set('utm_source', 'test')
const res = await fetch(url, {redirect: 'manual'})
assert.strictEqual(res.status, 200)
const html = await res.text()
assert.match(html, /meta http-equiv="refresh"/)
assert.match(
html,
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
// No blocked-site div, always safe
assert.doesNotMatch(html, /"blocked-site"/)
})
})
+5 -1
View File
@@ -14,6 +14,10 @@
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"ts-node": {
"logError": true,
"pretty": true /* <= technically not required */
}
}
+13 -1
View File
@@ -1,10 +1,22 @@
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'
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
return 'image/jpeg'
}
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
<img
{...others}
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
/>
)
}
+10 -4
View File
@@ -1,9 +1,9 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import {type Express} from 'express'
import satori from 'satori'
import {
@@ -11,7 +11,7 @@ import {
STARTERPACK_HEIGHT,
STARTERPACK_WIDTH,
} from '../components/StarterPack.js'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js'
@@ -83,12 +83,18 @@ export default function (ctx: AppContext, app: Express) {
}
async function getImage(url: string) {
const response = await fetch(url)
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',
+21 -14
View File
@@ -6,21 +6,26 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick
like:
# install nodejs
nvm install
nvm use
npm install --global yarn
```bash
# install nodejs
nvm install
nvm use
npm install --global yarn
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# run yarn web dev server, if you wanted
yarn web
# run yarn web dev server, if you wanted
yarn web
```
Then build and copy over the big 'ol `bundle.web.js` file:
# in the top level of this repo
yarn build-web
```bash
# in the top level of this repo
yarn build-web
```
### Golang Daemon
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
In this directory (`bskyweb/`):
# re-build and run daemon
go run ./cmd/bskyweb serve
```bash
# re-build and run daemon
go run ./cmd/bskyweb serve
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
```
The easiest way to configure the daemon is to copy `example.env` to `.env` and
fill in auth values there.
+7
View File
@@ -2,12 +2,14 @@ package main
import (
"net/url"
"strings"
"github.com/flosch/pongo2/v6"
)
func init() {
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
}
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
@@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
// Return the cleaned URL
return pongo2.AsValue(parsedURL.String()), nil
}
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
urlStr := in.String()
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
}
+1 -1
View File
@@ -54,7 +54,7 @@ func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer {
}
}
func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
func (r Renderer) Render(w io.Writer, name string, data any, c echo.Context) error {
var ctx pongo2.Context
if data != nil {
+30 -2
View File
@@ -292,6 +292,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
@@ -574,7 +575,10 @@ func (srv *Server) WebPost(c echo.Context) error {
if postView.Embed != nil && !isEmbedHidden {
hasImages := postView.Embed.EmbedImages_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasVideo := postView.Embed.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
if hasImages {
var thumbUrls []string
@@ -582,12 +586,36 @@ func (srv *Server) WebPost(c echo.Context) error {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMedia {
} else if hasVideo {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
}
}
} else if hasMediaImages {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMediaVideo {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
}
}
}
}
+1 -1
View File
@@ -11,6 +11,6 @@ type Template struct {
templates *template.Template
}
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error {
return t.templates.ExecuteTemplate(w, name, data)
}
+9 -2
View File
@@ -178,6 +178,13 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// Create CORS middleware for oembed
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
})
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
@@ -205,7 +212,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed)
e.GET("/oembed", server.WebOEmbed, oembedCORS)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
@@ -264,7 +271,7 @@ func (srv *Server) errorHandler(err error, c echo.Context) {
code = he.Code
}
c.Logger().Error(err)
data := map[string]interface{}{
data := map[string]any{
"statusCode": code,
}
c.Render(code, "error.html", data)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/bluesky-social/social-app/bskyweb
go 1.25
go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a
+5 -5
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin">
<!--
Preconnect to essential domains
@@ -148,11 +148,11 @@
</head>
<body>
{%- block body_all %}
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
<div id="root">
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
</div>
<noscript>
+10 -2
View File
@@ -34,9 +34,17 @@
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{%- if videoUrl %}
<meta property="og:video" content="{{ videoUrl }}">
<meta property="og:video:type" content="{{ videoType }}">
{%- if videoWidth %}
<meta property="og:video:width" content="{{ videoWidth }}">
<meta property="og:video:height" content="{{ videoHeight }}">
{% endif -%}
{% endif -%}
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
+510
View File
@@ -0,0 +1,510 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from './test-pds'
let server: TestPDS
// eslint-disable-next-line @typescript-eslint/no-misused-promises
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(await server.mocker.createPost(user, `Post ${i}`))
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account',
'muted-by-list-acc',
'blocking-account',
'blockedby-account',
'mutual-block-acc',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount('not-a-real-label', 'unknown-account')
await server.mocker.labelProfile('not-a-real-label', 'unknown-profile')
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createPost('unknown-posts', 'unknown post'),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createQuotePost(
'unknown-posts',
'unknown quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createReply(
'unknown-posts',
'unknown reply',
anchorPost,
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createPost('no-promote-posts', 'no-promote post'),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'muted quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'muted reply',
anchorPost,
)
const list = await server.mocker.createMuteList('alice', 'Muted Users')
await server.mocker.addToMuteList(
'alice',
list,
server.mocker.users['muted-by-list-acc'].did,
)
await server.mocker.createPost('muted-by-list-acc', 'muted post')
await server.mocker.createQuotePost(
'muted-by-list-acc',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-by-list-acc',
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('mutual-block-acc', 'mutual-block post')
await server.mocker.createQuotePost(
'mutual-block-acc',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-acc',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-acc'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-acc'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-acc'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
return res
.writeHead(200, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
pdsUrl: server.pdsUrl,
appviewDid: server.appviewDid,
}),
)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
+12
View File
@@ -0,0 +1,12 @@
{
"name": "dev-env",
"version": "0.0.0",
"scripts": {
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
},
"dependencies": {
"@atproto/dev-env": "^0.3.215",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
}
}
+2 -1
View File
@@ -28,6 +28,7 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": false,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"#/*": ["./src/*"],
"lib/*": ["./src/lib/*"],
@@ -43,4 +44,4 @@
"metro.config.js",
"jest.config.js"
]
}
}
+4424
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
- TextEncoder / TextDecoder
- react-native-url-polyfill
- Array#findLast (on web)
- setImmediate (on web)
### Sentry sourcemaps
+22 -8
View File
@@ -47,8 +47,7 @@ Every night, a GitHub action will run `yarn intl:extract` to update the english
### Release process
1. Pull main and create a branch.
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. Commit.
1. Run `yarn intl:extract:all` to ensure all `.po` files are synced with the current state of the code. Commit.
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. Create a PR, ensure the translations all look correct, and merge.
1. If needed:
1. Merge all approved translation PRs (contributions from outside crowdin).
@@ -73,7 +72,7 @@ import { Text } from "react-native";
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/macro";
import { Trans } from "@lingui/react/macro";
<Text><Trans>Hello World</Trans></Text>
```
@@ -90,18 +89,33 @@ const text = "Hello World";
```
In this case, you can use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/macro";
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
```jsx
import { useLingui } from "@lingui/react/macro";
const { t } = useLingui();
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
```
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/core/macro";
// not ideal - t only gets called once at module evaluation time
const text = t`Hello World`;
// however, this is suitable for strings that are ephemeral:
function sayHello() {
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
}
```
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.
@@ -121,7 +135,7 @@ So the workflow is as follows:
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
+14
View File
@@ -3,12 +3,26 @@
Make sure you've copied `.env.example` to `.env.test` and provided any required
values.
Install dependencies in `/dev-env`
```
cd dev-env && yarn
```
## Using Maestro
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
### Running on Android
You will need to allow your device access to the port that the mock server is running on.
```
adb reverse tcp:3000 tcp:3000
```
### Running Maestro tests
- In one tab, run `yarn e2e:mock-server`
+23 -3
View File
@@ -23,8 +23,6 @@ export default defineConfig(
{
ignores: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party/**',
'ios/**',
'android/**',
'coverage/**',
@@ -39,6 +37,7 @@ export default defineConfig(
'*.e2e.ts',
'*.e2e.tsx',
'eslint.config.mjs',
'.jscodeshift/**',
],
},
@@ -48,7 +47,6 @@ export default defineConfig(
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
// @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
importX.flatConfigs['react-native'],
@@ -63,6 +61,7 @@ export default defineConfig(
'react-native': reactNative,
'react-native-a11y': reactNativeA11y,
'simple-import-sort': simpleImportSort,
// @ts-expect-error - not sure why
lingui,
'react-compiler': reactCompiler,
'bsky-internal': bskyInternal,
@@ -128,6 +127,7 @@ export default defineConfig(
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/hook-use-state': 'warn',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
@@ -190,6 +190,18 @@ export default defineConfig(
*/
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',
/**
* TypeScript-specific rules
@@ -238,6 +250,14 @@ 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
*/
+1
View File
@@ -29,6 +29,7 @@ function getTagName(node) {
return reversedIdentifiers.reverse().join('.')
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
+1
View File
@@ -3,6 +3,7 @@ const BANNED_IMPORTS = [
'@fortawesome/free-solid-svg-icons',
]
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
+1
View File
@@ -10,6 +10,7 @@ const BANNED_IMPORT_PREFIXES = [
'view/',
]
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
+3 -2
View File
@@ -1,7 +1,5 @@
/* global jest */
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
import {configure} from '@testing-library/react-native'
@@ -11,6 +9,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
// eslint-disable-next-line import-x/no-nodejs-modules
const {EventEmitter} = require('events')
return {
__esModule: true,
@@ -36,6 +35,7 @@ jest.mock('react-native-safe-area-context', () => {
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
moveAsync: jest.fn().mockResolvedValue(undefined),
createDownloadResumable: jest.fn(),
}))
@@ -45,6 +45,7 @@ jest.mock('expo-image-manipulator', () => ({
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
-23
View File
@@ -1,23 +0,0 @@
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {render} from '@testing-library/react-native'
import {ThemeProvider} from '../src/lib/ThemeContext'
import {type RootStoreModel, RootStoreProvider} from '../src/state'
const customRender = (ui: any, rootStore: RootStoreModel) =>
render(
<GestureHandlerRootView style={{flex: 1}}>
<RootStoreProvider value={rootStore}>
<ThemeProvider theme="light">
<SafeAreaProvider>{ui}</SafeAreaProvider>
</ThemeProvider>
</RootStoreProvider>
</GestureHandlerRootView>,
)
// re-export everything
export * from '@testing-library/react-native'
// override render method
export {customRender as render}
+6 -4
View File
@@ -1,5 +1,7 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
import {defineConfig} from '@lingui/cli'
export default defineConfig({
sourceLocale: 'en',
locales: [
'en',
'an',
@@ -49,5 +51,5 @@ module.exports = {
include: ['src'],
},
],
format: 'po',
}
compileNamespace: 'ts',
})
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.android.material:material:1.13.0'
implementation "com.facebook.react:react-native:+"
}
@@ -25,8 +25,8 @@ class BottomSheetModule : Module() {
view.dismiss()
}
AsyncFunction("updateLayout") { view: BottomSheetView ->
view.updateLayout()
Prop("fullHeight") { view: BottomSheetView, prop: Boolean ->
view.fullHeight = prop
}
Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
@@ -48,6 +48,8 @@ class BottomSheetModule : Module() {
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.preventExpansion = prop
}
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
}
}
}
@@ -5,9 +5,10 @@ import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.allViews
import androidx.core.view.WindowInsetsControllerCompat
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
@@ -15,6 +16,7 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -30,21 +32,44 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private val rawScreenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
// Native content height observation (eliminates JS bridge round-trip)
private var contentLayoutListener: OnLayoutChangeListener? = null
private var observedChildren: List<View> = emptyList()
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set(value) {
field = value
@@ -55,49 +80,39 @@ class BottomSheetView(
set(value) {
field = value
this.dialog?.setCancelable(!value)
// Full-height sheets have no half-expanded snap point, so any drag
// would dismiss. Disable dragging when dismiss is prevented.
if (fullHeight) {
this.setDraggable(!value && !disableDrag)
}
}
var fullHeight = false
var preventExpansion = false
var minHeight = 0f
set(value) {
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.safeScreenHeight
var maxHeight = this.screenHeight
set(value) {
val px = dpToPx(value)
field =
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
field = if (px > this.screenHeight) this.screenHeight else px
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
onStateChange(mapOf("state" to if (value) "open" else "closed"))
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "opening",
),
)
onStateChange(mapOf("state" to "opening"))
}
}
@@ -105,33 +120,21 @@ class BottomSheetView(
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "closing",
),
)
onStateChange(mapOf("state" to "closing"))
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
onSnapPointChange(mapOf("snapPoint" to value))
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
@@ -149,6 +152,7 @@ class BottomSheetView(
}
private fun destroy() {
this.stopObservingContentHeight()
this.isClosing = false
this.isOpen = false
this.dialog = null
@@ -161,45 +165,92 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float =
when {
// Full height sheets
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
val dialog = BottomSheetDialog(context)
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
dialog.setOnShowListener {
dialog.window?.let { window ->
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (originalNavBarAppearance != null) {
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
if (originalStatusBarAppearance != null) {
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
}
}
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
it.elevation = 0f
val behavior = BottomSheetBehavior.from(it)
behavior.state = BottomSheetBehavior.STATE_HIDDEN
behavior.isFitToContents = true
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.skipCollapsed = true
behavior.isDraggable = true
behavior.isHideable = true
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
if (fullHeight) {
behavior.isFitToContents = false
behavior.expandedOffset = getStatusBarHeight()
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
} else if (preventExpansion) {
behavior.isFitToContents = true
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
this.selectedSnapPoint = 1
} else {
behavior.isFitToContents = false
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.expandedOffset = getStatusBarHeight()
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
this.selectedSnapPoint = 1
}
}
behavior.addBottomSheetCallback(
@@ -208,19 +259,22 @@ class BottomSheetView(
bottomSheet: View,
newState: Int,
) {
if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
return
}
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
}
// Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) {
pendingLayoutUpdate = false
updateLayout()
}
}
@@ -231,12 +285,18 @@ class BottomSheetView(
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
if (!fullHeight) {
this.startObservingContentHeight()
}
}
fun updateLayout() {
if (fullHeight) return
val dialog = this.dialog ?: return
val contentHeight = this.getContentHeight()
@@ -246,12 +306,37 @@ class BottomSheetView(
val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight)
val newRatio = getHalfExpandedRatio(contentHeight)
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
// Don't update during user gestures — defer until the gesture completes.
if (currentState == BottomSheetBehavior.STATE_DRAGGING) {
pendingLayoutUpdate = true
return
}
behavior.halfExpandedRatio = newRatio
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
it.requestLayout()
}
// During settling (programmatic animation from our own state change),
// redirect the animation to the new position if the ratio changed.
if (currentState == BottomSheetBehavior.STATE_SETTLING) {
if (oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
return
}
if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -260,44 +345,94 @@ class BottomSheetView(
}
fun dismiss() {
this.dialog?.dismiss()
val dialog = this.dialog ?: return
// Mark as closing so the content observer doesn't fight the dismiss
// animation by calling updateLayout() mid-hide.
this.isClosing = true
// Temporarily make cancelable so cancel() works — cancel() gives the
// slide-out animation, while dismiss() does a plain fade.
dialog.setCancelable(true)
dialog.cancel()
}
// Observe each direct child of innerView via OnLayoutChangeListener so that
// height updates are detected purely on the native side. We use OnLayoutChangeListener
// (not OnGlobalLayoutListener) because React Native calls view.layout() directly
// via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener
// fires from setFrame() which IS called by layout(), so it catches RN updates.
private fun startObservingContentHeight() {
stopObservingContentHeight()
val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
}
val children = mutableListOf<View>()
for (i in 0 until innerViewGroup.childCount) {
val child = innerViewGroup.getChildAt(i)
child.addOnLayoutChangeListener(listener)
children.add(child)
}
this.contentLayoutListener = listener
this.observedChildren = children
// Pick up current height if content is already laid out
val contentHeight = getContentHeight()
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
private fun stopObservingContentHeight() {
contentLayoutListener?.let { listener ->
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
}
contentLayoutListener = null
observedChildren = emptyList()
lastObservedContentHeight = 0f
}
// Util
private fun getContentHeight(): Float {
val innerView = this.innerView ?: return 0f
var index = 0
innerView.allViews.forEach {
if (index == 1) {
return it.height.toFloat()
}
index++
val innerView = this.innerView as? ViewGroup ?: return 0f
// Use the tallest direct child's height. The handle is absolutely positioned
// (overlaps the content), so summing would double-count its height as padding.
var maxChildHeight = 0f
for (i in 0 until innerView.childCount) {
val h = innerView.getChildAt(i).height.toFloat()
if (h > maxChildHeight) maxChildHeight = h
}
return 0f
return maxChildHeight
}
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
return when {
contentHeight > maxHeight -> maxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
}
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
private fun clampRatio(ratio: Float): Float =
when {
ratio < 0.01 -> 0.01f
ratio > 0.99 -> 0.99f
else -> ratio
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
@@ -322,9 +457,7 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) {
return
}
if (structure == null) return
dialogRootViewGroup.dispatchProvideStructure(structure)
}
@@ -363,7 +496,6 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
}
}
@@ -52,6 +52,8 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
fitsSystemWindows = false
}
override fun onSizeChanged(
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="ThemeOverlay.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge, matching react-native-edge-to-edge's setup -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:fitsSystemWindows">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
<item name="backgroundTint">@android:color/transparent</item>
</style>
</resources>
@@ -19,8 +19,8 @@ public class BottomSheetModule: Module {
view.dismiss()
}
AsyncFunction("updateLayout") { (view: SheetView) in
view.updateLayout()
Prop("fullHeight") { (view: SheetView, prop: Bool) in
view.fullHeight = prop
}
Prop("cornerRadius") { (view: SheetView, prop: Float) in
@@ -42,6 +42,10 @@ public class BottomSheetModule: Module {
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
view.sourceViewTag = prop
}
}
}
}
+42 -9
View File
@@ -8,6 +8,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
private var innerView: UIView?
private var touchHandler: RCTTouchHandler?
// Native content height observation (eliminates JS bridge round-trip)
private var contentHeightObservation: NSKeyValueObservation?
// Events
private let onAttemptDismiss = EventDispatcher()
private let onSnapPointChange = EventDispatcher()
@@ -23,9 +26,11 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
// React view props
var fullHeight = false
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var sourceViewTag: Int?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
@@ -67,7 +72,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
}
}
private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier?
// MARK: - Lifecycle
@@ -105,6 +109,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
private func destroy() {
self.contentHeightObservation?.invalidate()
self.contentHeightObservation = nil
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
@@ -127,7 +133,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
let sheetVc = SheetViewController()
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight)
if let sheet = sheetVc.sheetPresentationController {
sheet.delegate = self
sheet.preferredCornerRadius = self.cornerRadius
@@ -135,8 +141,20 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
sheetVc.view.addSubview(innerView)
if #available(iOS 26.0, *),
let tag = self.sourceViewTag,
let bridge = self.appContext?.reactBridge,
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
sheetVc.preferredTransition = .zoom { _ in
return sourceView
}
}
self.sheetVc = sheetVc
self.isOpening = true
if !self.fullHeight {
self.startObservingContentHeight()
}
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
@@ -144,15 +162,30 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
}
func updateLayout() {
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil,
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
// Observe the content view's bounds via KVO so that height changes are detected
// purely on the native side, without a JS bridge round-trip through onLayout.
// Calls updateDetents directly with the observed height rather than going through
// updateLayout(), which has a prevLayoutDetentIdentifier guard that can block
// legitimate content-driven updates when detent identifiers drift during animations.
private func startObservingContentHeight() {
self.contentHeightObservation?.invalidate()
guard let contentView = self.innerView?.subviews.first else { return }
self.contentHeightObservation = contentView.observe(
\.bounds,
options: [.old, .new]
) { [weak self] _, change in
guard let self = self,
(self.isOpen || self.isOpening) && !self.isClosing,
let oldBounds = change.oldValue,
let newBounds = change.newValue,
oldBounds.height != newBounds.height,
newBounds.height > 0 else { return }
let clampedHeight = self.clampHeight(newBounds.height)
self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion)
self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
}
self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier
}
func dismiss() {
@@ -20,13 +20,32 @@ class SheetViewController: UIViewController {
}
}
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
func setDetents(contentHeight: CGFloat, preventExpansion: Bool, fullHeight: Bool = false) {
guard let sheet = self.sheetPresentationController,
let screenHeight = Util.getScreenHeight()
else {
return
}
if fullHeight {
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
return
}
// On iOS 26, the floaty sheet presentation adds the device bottom safe area
// on top of the custom detent value, creating visible padding inside the pill.
// Subtract it so the pill height matches our actual content.
var bottomSafeAreaAdjustment: CGFloat = 0
if #available(iOS 26.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
bottomSafeAreaAdjustment = window.safeAreaInsets.bottom
}
}
let adjustedHeight = contentHeight - bottomSafeAreaAdjustment
if #available(iOS 16.0, *) {
if contentHeight > screenHeight - 100 {
sheet.detents = [
@@ -36,7 +55,7 @@ class SheetViewController: UIViewController {
} else {
sheet.detents = [
.custom { _ in
return contentHeight
return adjustedHeight
}
]
if !preventExpansion {
@@ -1,5 +1,4 @@
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
@@ -25,7 +24,9 @@ export interface BottomSheetViewProps {
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
disableDrag?: boolean
sourceViewTag?: number
fullHeight?: boolean
minHeight?: number
maxHeight?: number
@@ -5,21 +5,21 @@ import {
type NativeSyntheticEvent,
Platform,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {IS_IOS} from '#/env'
import {
type BottomSheetState,
type BottomSheetViewProps,
} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
import {
BottomSheetPortalProvider,
Context as PortalContext,
} from './BottomSheetPortal'
const NativeView: React.ComponentType<
BottomSheetViewProps & {
@@ -34,6 +34,10 @@ const IS_IOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
// older android versions (15 and below) aren't naturally edge-to-edge
// and behave a little differently
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
BottomSheetViewProps,
@@ -70,10 +74,6 @@ export class BottomSheetNativeComponent extends React.Component<
this.props.onStateChange?.(event)
}
private updateLayout = () => {
this.ref.current?.updateLayout()
}
static dismissAll = async () => {
await NativeModule.dismissAll()
}
@@ -92,6 +92,7 @@ export class BottomSheetNativeComponent extends React.Component<
let extraStyles
if (IS_IOS15 && this.state.viewHeight) {
const screenHeight = Dimensions.get('screen').height
const {viewHeight} = this.state
const cornerRadius = this.props.cornerRadius ?? 0
if (viewHeight < screenHeight / 2) {
@@ -111,23 +112,14 @@ export class BottomSheetNativeComponent extends React.Component<
nativeViewRef={this.ref}
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
if (IS_IOS15) {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
if (Platform.OS === 'android') {
// TEMP HACKFIX: I had to timebox this, but this is Bad.
// On Android, if you run updateLayout() immediately,
// it will take ages to actually run on the native side.
// However, adding literally any delay will fix this, including
// a console.log() - just sending the log to the CLI is enough.
// TODO: Get to the bottom of this and fix it properly! -sfn
setTimeout(() => this.updateLayout())
} else {
this.updateLayout()
}
}}
onLayout={
IS_IOS15
? e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
: undefined
}
/>
</Portal>
)
@@ -148,12 +140,18 @@ function BottomSheetNativeComponentInner({
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: React.RefObject<View>
onLayout: (event: LayoutChangeEvent) => void
onLayout?: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions()
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
// for the sheet content
const sheetHeight = IS_NON_E2E_ANDROID
? screenHeight + insets.bottom
: screenHeight - insets.top
return (
<NativeView
@@ -175,6 +173,7 @@ function BottomSheetNativeComponentInner({
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
overflow: 'hidden',
},
extraStyles,
]}>

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