Compare commits

...

173 Commits

Author SHA1 Message Date
Samuel Newman c81a43d2a3 bump version to v1.117.0 2026-02-09 23:01:39 +00: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
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
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
Samuel Newman 45735144ee adjust splash size (#9797) 2026-01-30 04:45:43 -08:00
pfrazee 4b86444bd8 Nightly source-language update 2026-01-30 03:07:37 +00:00
Samuel Newman 9ff5b2da50 Revert splash screen changes (#9792)
* Revert splash screen changes

This partially reverts commit 0ac5b07bfd.

* Keep android changes
2026-01-29 15:23:05 -06:00
Samuel Newman 58f532a495 [Drafts] Storage fixes (#9790)
* delete media from existsCache when deleting

* revoke media URLs

* skip revoking objecturls until the composer is completely closed

* [Drafts] Metrics (#9794)

* metrics for drafts

* Nit: format

* nit: use new util for clarity

* nit: use new util for clarity

---------

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-29 15:21:38 -06:00
Samuel Newman 0babd0f475 [Drafts] Revert unnecessary gif changes (#9788)
* revert unnecessary gif changes

* Update ExternalEmbed.tsx
2026-01-29 15:08:48 -06:00
Samuel Newman 2cee96da8e Add ESLint rule to enforce Lingui msg usage (#9789)
* Add ESLint rule to enforce Lingui msg usage

Adds a custom ESLint rule 'lingui-msg-rule' that ensures the Lingui _()
function is called with msg`` template literals or plural/select macros,
preventing accidental misuse like _('string') which bypasses i18n.

https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy

* Support msg({...}) descriptor form and add auto-fix

- Allow msg() function call form: _(msg({message: 'Hello'}))
- Add auto-fix for string literals: _('Bad') -> _(msg`Bad`)
- Add auto-fix for untagged templates: _(`Bad`) -> _(msg`Bad`)
- No auto-fix for variables/function calls (not safely fixable)

https://claude.ai/code/session_01JMXXPUgAHiSBGmfGwUojKy

* fix complex cases

* run autofix HELL YEAH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 11:05:49 -08:00
pfrazee 31553f0d40 Nightly source-language update 2026-01-29 03:04:03 +00:00
Eric Bailey 0ac5b07bfd [APP-1801] New splash screen (#9780)
* MVP splash

* MVP of login screen

* Hack a footer into the logged out view

* revert unneeded

(cherry picked from commit 296420aa0df433b101389c44b05a40696e7389ae)

* Update config

(cherry picked from commit eac421966dfdee99d4c818ac07835a2a97e7590f)

* Bump size slightly
2026-01-28 20:05:14 -06:00
Samuel Newman d13df6c7e6 Drafts (#9691)
* Add drafts functionality to composer

- Add local storage layer for drafts (filesystem on native, IndexedDB on web)
- Add "Drafts" button to composer top bar showing badge with draft count
- Modify discard prompt to offer "Save Draft" option
- Add `restore_from_draft` action to composer reducer
- Support saving/restoring: text, facets, images, labels, threadgate, quote/link embeds
- Add placeholder hooks for future server API integration
- Add unit tests for draft serialization

Note: Video/GIF restoration marked as TODO for future implementation

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

* Fix drafts button: always visible, adjacent to post button

- Make drafts button always visible (not just when drafts exist)
- Move button to be adjacent to the publish button
- If composer is empty: opens drafts list directly
- If composer has content: shows prompt to save/discard before viewing drafts
- Add badge showing draft count when drafts exist

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

* Update drafts button: text-only, ghost/primary style

- Show "Drafts" or "Drafts (N)" as text, no icon
- Use ghost variant with primary color
- Match Cancel button styling

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

* Remove draft count from button, just show "Drafts"

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

* Increase drafts button horizontal padding and gap

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

* Use InnerFlatList and Dialog.Header for drafts dialog

- Switch from ScrollableInner to InnerFlatList
- Add Dialog.Header with back button in left slot
- Use sticky header

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

* Track draft ID in composer state machine

Adds draftId to ComposerState so that editing an existing draft and
saving it again updates the draft rather than creating a new one.

- Add draftId?: string to ComposerState type
- Set draftId when restoring from draft via restore_from_draft action
- Pass existingDraftId to save functions from composerState.draftId
- Add PageX icon for empty drafts state

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

* Add clear action to discard composer content

When pressing the Drafts button with content in the composer, the user
can choose to discard. This now properly clears the composer by
dispatching a 'clear' action that resets to an empty state.

- Add 'clear' action type to ComposerAction
- Implement clear case in composerReducer (resets to single empty post)
- Add handleClearComposer callback in Composer.tsx
- Pass onDiscard prop through ComposerTopBar to DraftsButton
- Call onDiscard before opening drafts dialog on discard

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

* Track dirty state to skip discard prompt for unchanged drafts

When a draft is loaded and the user hasn't made any changes, closing
the composer should not show the discard prompt since nothing would
be lost.

- Add isDirty field to ComposerState
- Set isDirty: true on all content-modifying actions
- Set isDirty: false on restore_from_draft, clear, and initial state
- Update onPressCancel to only show prompt if no draft or isDirty

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

* Redesign drafts list to show full thread preview

- Display all posts in a draft thread, not just the first
- First post uses larger avatar (42px), subsequent posts nested with
  smaller avatar (32px) and thread connector line
- Show author avatar, display name, handle, and relative timestamp
- Add overflow menu button (placeholder) on first post
- Display full text instead of truncated preview
- Add media preview component for images, GIFs, and videos
- Card layout with rounded corners and proper spacing
- Add gap separators between draft cards in list

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

* Fix draft item styling based on feedback

- Add border and shadow to draft cards
- Remove trash button, move delete to overflow menu prompt
- Remove size differences for thread posts (same avatar/text size)
- Add spacing between header and first draft item
- Change prompt wording to "Discard draft"

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

* Use real image embed components for draft preview

Replace custom image preview with AutoSizedImage for single images
and ImageLayoutGrid for multiple images. This gives drafts the same
polished image display as regular posts.

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

* Improve drafts dialog platform handling

- Render header outside FlatList on native, inside on web
- Use web() helper for conditional web-only props
- Replace ItemSeparatorComponent with mt_lg margin on items
- Add minHeight on web for better dialog sizing
- Simplify header structure

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

* Mark composer as clean after saving draft

Add mark_saved action that resets isDirty to false and updates the
draftId. This is dispatched after successfully saving a draft, allowing
the user to close the composer without a discard prompt since their
changes have been saved.

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

* Apply dirty tracking to Drafts button prompt

Only show the save/discard prompt when pressing the Drafts button if
the composer has unsaved changes (isDirty). If the content is unchanged
from a loaded draft or was just saved, go directly to the drafts list.

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

* Fix re-saving drafts with existing media

When re-saving a draft, the code was trying to copy media files that
were already in drafts storage to new locations, causing copy errors.

Changes:
- Add extractLocalIdFromPath() to detect if a path is already in drafts
- Track loadedMediaMap in ComposerState for identifying reusable media
- Only delete old media that wasn't reused during re-save
- Pass loadedMediaMap when saving to enable media reuse detection
- Disable pointer events on draft media preview

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

* add vertical option to prompt, change copy

* fix import

* fix import

* Migrate drafts from local storage to server API

Replace local-only draft storage with the new `app.bsky.draft.*` server API:
- getDrafts, createDraft, updateDraft, deleteDraft endpoints

Key changes:
- Add api.ts with type converters (ComposerState <-> server Draft)
- Update hooks.ts to use server API instead of local storage
- Simplify storage.ts/storage.web.ts for local media caching only
- Media stored locally via localRef pattern (filepath in server draft)
- GIFs stored as external embeds with Tenor URL + dimensions
- Hide drafts button when replying (reply drafts not supported)
- Show "different device" note when media is missing locally

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

* attempt to fix blob mangement

* Migrate storage.ts from expo-file-system/legacy to expo-file-system

Use the new object-based expo-file-system API (SDK 54+) with Directory
and File classes instead of the legacy function-based API. The new API
provides synchronous operations for file/directory existence checks,
creation, copying, deletion, and listing.

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

* feat: add drafts-specific logger

Add a Drafts context to the logger system for better log categorization
and debugging of draft-related operations.

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

* refactor: use drafts-specific logger in hooks and storage

Switch from the generic logger to the new drafts-specific logger
for better log categorization.

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

* fix: ensure media cache is populated before checking exists

On iOS (and web), the media cache wasn't populated before the drafts
query ran, causing drafts with local media to incorrectly show as
"missing media" on app restart. The issue would resolve itself after
closing and reopening the composer because by then the cache was ready.

This fix adds ensureMediaCachePopulated() and awaits it in useDrafts
before checking which media exists locally.

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

* fix: complete Gif object reconstruction for draft rehydration

Fix "Cannot read property 'url' of undefined" error when rehydrating
drafts with GIFs. The Gif object was missing required properties like
url, content_description, and media_formats.preview that are needed
by useResolveGifQuery and other components.

Also preserve alt text through serialization by storing it in URL
query params alongside dimensions.

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

* fix: load draft preview images and get dimensions

Fix draft preview images not showing on web and add proper aspect
ratio support:

1. Try to load all images regardless of the exists cache flag, which
   may be stale due to async cache population timing
2. Use Image.loadAsync() from expo-image to get image dimensions
3. Pass dimensions to viewImages for proper aspect ratio in previews

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

* fix: update save prompt copy when editing existing draft

When editing an existing draft (vs creating a new one), use "Save
changes" instead of "Save draft" in the save/discard prompts. This
provides clearer context to the user about what action they're taking.

Add isEditingDraft prop to DraftsButton and ComposerTopBar, and
update both prompts (in DraftsButton and Composer) with conditional
copy based on whether we're editing an existing draft.

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

* style: add bottom padding to drafts list

Add pb_xl padding to the drafts list content container for better
visual spacing at the bottom of the list.

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

* refactor: use typed error check for draft limit

Replace manual error object inspection with the proper
AppBskyDraftCreateDraft.DraftLimitReachedError type check for
cleaner and more reliable error handling.

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

* make storage functions async to match web

* log unknown errors

* delete left-over file

* move state to colo with composer

* fix: handle invalid GIF dimensions gracefully

Fix NaN aspectRatio when rehydrating GIFs from drafts by:

1. Adding validation in parseTenorGif to reject invalid dimensions
   (NaN, zero, or negative values)
2. Adding defensive checks in GifEmbed to fallback to 1:1 aspect
   ratio if dimensions are invalid
3. Adding defensive checks in composer ExternalEmbedGif to fallback
   to 16:9 if gif.media_formats.gif.dims is missing or invalid

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

* fix: prevent double query string in GIF draft hydration

When loading a GIF from a draft, the URL was being corrupted with
double query strings like:
`?ww=498&hh=498?hh=498&ww=498`

This happened because:
1. serializeGif() adds ?ww=X&hh=Y&alt=Z to the Tenor URL
2. parseGifFromUrl() returned the full URL including our params
3. resolveGif() in resolve.ts then appends MORE params via string
   concatenation, creating a second ?

Fix: Strip our custom params (ww, hh, alt) from the URL in
parseGifFromUrl() before returning it, so the reconstructed GIF
has a clean base URL.

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

* Fix draft button behaviour when publishing, tweak buttons

* ensure media unavaiable message is contrasty enough

* infinite query, rename file to queries

* simplify threadgate/postgate handling

* refactor: pass full draft data instead of re-fetching

The useLoadDraft and useDeleteDraftMutation hooks were fetching drafts
via getDrafts() to look up a draft by ID. This was problematic because
getDrafts is paginated, so drafts not on the first page wouldn't be
found.

Changes:
- Add full Draft object to DraftSummary type
- useLoadDraft now takes Draft directly (only loads local media)
- useDeleteDraftMutation now takes {draftId, draft} to avoid re-fetch
- Update DraftItem and DraftsListDialog to pass full draft data

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

* fix type errors

* docs: add notes on platform files and paginated APIs

- Platform-specific files (.web.ts, .native.ts) are resolved by the
  bundler automatically - just import normally, don't use require()
- Paginated APIs should use useInfiniteQuery, not useQuery

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

* More CLAUDE.md updates

* Delete PLAN.md

* Use minimal media mode for draft display - REVERT IF NEEDED

* Enable pagination

* Add comment about headers

* remove extraneous comments

* Prevent runaway pagination

* fix detection rebase change

* use border_transparent

* Replace idb with idb-keyval for draft media storage

Simplifies web IndexedDB storage by using idb-keyval instead of the
full idb library. This reduces bundle size and aligns with the pattern
used in src/storage/archive/db/index.web.ts.

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

* Fix native draft media filename encoding

The previous approach replaced both / and : with _, but the reverse
transformation couldn't distinguish between them. This caused cache
misses for paths containing both characters.

Use encodeURIComponent/decodeURIComponent for a proper reversible
encoding.

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

* convert useLoadDraft() hook to regular async function

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

* update atproto api

* clean up orphaned media

* restore videos

* save/restore captions

* restore postgates

* Copy updates from Darrin

* Ope fix missed vertical props

* get image aspect ratio when restoring

* get videos working on native

* get video restoration working on native

* sanitize handles properly in draftitem

* fix yarn.lock

* Swap console logs

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-28 16:54:26 -06:00
bnewbold a18f1b68e8 CI: auto-detect go runtime version from go.mod (#9766) 2026-01-28 13:16:55 -08:00
Spence Pope b9c92d39eb [APP-1761] Prevent welcome modal from reappearing (#9786)
* store welcome modal appearance in localstorage

* lint

* redundancy
2026-01-28 10:41:48 -08:00
Samuel Newman d45a2c832d Redo web lightbox, add animations and keyboard a11y (#9481)
* redo web lightbox, add animations

* add rect-avi to shell

* fix lingui msgs
2026-01-28 10:13:52 -08:00
Matthieu Sieben fd49349b71 update atproto deps (#9784) 2026-01-28 10:46:48 -06:00
Samuel Newman 277e985e1f prevent lightbox initial jitter (#9762) 2026-01-28 07:46:53 -08:00
Samuel Newman 3b2573bc56 [Lighbox perf - 1] Fix memoization of List component, defer re-rendering when opening lightbox (#9759)
* memoize FlatList_INTERNAL

* defer render, put handleScrolledDownChange in non reactive callback

* lintfix useDedupe
2026-01-28 07:35:43 -08:00
pfrazee ae77320d81 Nightly source-language update 2026-01-28 02:51:12 +00:00
Eric Bailey 72b7516d9b Prompts refresh (#9781)
* Round boi

* Wrap a few things in Prompt.Content

* Couple tweaks
2026-01-27 20:02:55 -06:00
Samuel Newman 220e989f3d improve lightbox smoothness (#9200) 2026-01-27 16:57:49 -06:00
Eric Bailey 39e7132fd8 [APP-1784] Proper fix for Live Now status not showing (#9779)
* Be sure to validate if actor is allowed to be live on certain domain

* Rename for clarity

* Memoize
2026-01-27 16:44:27 -06:00
Samuel Newman 26605ee730 Fix console logging in Firefox and other browsers (#9775)
Use CSS-based %c styling for browser consoles instead of ANSI escape codes, which don't render properly in Firefox. Keep ANSI codes for native/terminal environments.

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-27 16:24:29 -06:00
Spence Pope f1c50baa4e link to labelers by did (#9777) 2026-01-27 13:34:21 -08:00
Samuel Newman e49ce42a12 Disable KeyboardProvider preload for faster initial load (#9778)
Adds preload={false} to KeyboardProvider to prevent unnecessary keyboard controller initialization during app startup.

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-27 12:07:08 -08:00
Eric Bailey fd6b8fcbf1 Fix Live Now not showing for non-streamer (#9773)
* Fix Live Now not showing for non-streamer

* Rename for clarity

* Logged out users too
2026-01-27 12:16:54 -06:00
Samuel Newman 2953b3a094 Typecheck app.config.js (#9771)
* typecheck app.config.js

* fix eslint warning

* use import.meta.url instead of __dirname

* dirname, not url
2026-01-27 09:09:06 -08:00
Samuel Newman 69a765cc0d enable ccache for dev builds on iOS (#9770) 2026-01-27 08:46:05 -08:00
Samuel Newman dd7bb1bc14 fix IS_DEV in app config (#9769) 2026-01-27 06:22:32 -08:00
Alex Benzer d31bc7f335 Update suggested accounts heading (#9765) 2026-01-27 05:49:18 -08:00
Eric Bailey f26ac583e6 Add idb-keyval backed archival storage (#9754)
* Add idb-keyval backed archival storage

* Cleanup

* Remove test
2026-01-26 13:39:12 -06:00
pfrazee 2ad84d7dfc Nightly source-language update 2026-01-26 02:58:16 +00:00
Eric Bailey 282b5e8460 Bump version to 1.116.0 (#9751) 2026-01-25 16:04:27 -06:00
Eric Bailey f82bc98d8d Allow users to dismiss sidebar LEF banner (#9753) 2026-01-25 15:48:18 -06:00
Eric Bailey 3b7806963a Add more Live Event refreshing (#9745)
* Refresh on foreground

* Add back interval
2026-01-24 13:39:55 -06:00
pfrazee 49e7a96cd9 Nightly source-language update 2026-01-24 02:45:32 +00:00
Eric Bailey 3523e7ce58 1.115.0 fast follows (#9743)
* Fix caching, fix attributes setting

* Use useCallOnce hook

* Update dismissal handling

* Update copy
2026-01-23 16:28:40 -06:00
Eric Bailey 967b3b49d9 1.114.1 Release Prep (#9738)
* intl pull, extract:all

* Clean cache for e2e
2026-01-22 17:51:53 -06:00
Eric Bailey 97083bb55b Enable Bluecast for Live Now (#9737) 2026-01-22 16:59:13 -06:00
Eric Bailey df89b79c2a [APP-1787] Some analytics cleanup (#9736)
* Clean up flags

* Move getMetadataForLogger

* Nail down attributers

* Polyfill features cache
2026-01-22 16:57:59 -06:00
Samuel Newman 6534630d16 Refactor GrowthHack to always render on iOS with enabled prop (#9723)
Drill down an `enabled` prop to ThreadItemAnchorFollowButtonInner so
that the GrowthHack wrapper is always rendered on iOS, even when the
follow button shouldn't be displayed. The inner component returns null
when disabled, allowing GrowthHack to render its logo without children.

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-22 16:48:33 -06:00
Eric Bailey 25a1ed1209 [APP-1782] Analytics migration (#9734)
* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests
2026-01-22 15:33:45 -06:00
bnewbold 8614e5e105 bump copyright year to 2026 (#9732) 2026-01-20 08:50:07 +02:00
pfrazee 7da65efc08 Nightly source-language update 2026-01-20 02:49:52 +00:00
Eric Bailey 4f01d4cc81 Resolve same version of @types/estree (#9731) 2026-01-19 16:20:08 -06:00
Eric Bailey b4f57b94aa Fix Live Now reporting container collapse on Android (#9730) 2026-01-19 15:33:15 -06:00
Eric Bailey bd9e8244f1 [APP-1783] Clean up some feature gates and logging (#9729)
* Remove gates we don't need

* Clean up some logging

* Keep disable gates around for now

* Add missing gate

* Revert file

* Revert package changes
2026-01-19 15:32:02 -06:00
Eric Bailey 5989bf7fad Remove some unused things (#9719) 2026-01-19 10:48:13 -06:00
Samuel Newman a4f173d008 Bump version to 1.115.0 (#9726)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 20:03:45 +02:00
pfrazee f244afcd9c Nightly source-language update 2026-01-17 02:42:35 +00:00
Eric Bailey 62c0c06bbf Move browser env into main env (#9718) 2026-01-16 17:20:03 -06:00
Eric Bailey 0589bd7d9e Move /platform/detection vars into /env (#9707)
* Add platform vars to env

* Replace /platform/detection with /env
2026-01-16 16:28:17 -06:00
Samuel Newman bd510d8468 Upgrade ESLint to v9 with flat config (#9680)
* Upgrade ESLint to v9 with flat config

- Upgrade eslint from v8 to v9.18.0
- Migrate from .eslintrc.js to eslint.config.mjs (flat config)
- Upgrade typescript-eslint to v8.20.0 (unified package)
- Replace eslint-plugin-import with eslint-plugin-import-x for flat config support
- Add globals package for environment globals
- Update eslint-plugin-bsky-internal with proper meta objects for ESLint v9
- Fix deprecated context.getScope() API usage
- Update bskyembed to use flat config
- Remove deprecated --ext flag from lint scripts
- Configure rules to maintain previous behavior while using new ESLint version

* Fix varsIgnorePattern to require character after underscore

Restore the original pattern `^_.+` instead of `^_` so that lingui's
`const { _ } = useLingui()` will still be flagged when unused.

* Update ESLint rule tests for flat config format

- Update RuleTester to use flat config languageOptions instead of
  eslintrc parser format
- Remove duplicate test case that ESLint v9 now detects
- Add Jest globals for test files

* update eslint package versions

* lint android a11y

* enable typechecked rules, switch them to warn

* fix yarn lock ci

* Fix CI failure

* Remove unused globals?

* Organize a bit, add quiet to main lint command

* Allow ternary

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-16 16:24:28 -06:00
Samuel Newman acc26c965d make live now gate fail-open (#9714) 2026-01-16 08:34:00 -08:00
Samuel Newman fbd1138d97 Add fishing minigame (#9713) 2026-01-16 08:31:01 -08:00
Samuel Newman 9c563f2a48 Fix collapsed button by simplifying, keep container width (#9712) 2026-01-16 10:18:41 -06:00
Eric Bailey 1d4263078d Refetch live events every 5 min (#9706) 2026-01-15 16:26:44 -06:00
Eric Bailey 367eebe600 Reset default AA debug for E2E (#9705) 2026-01-15 13:05:11 -06:00
Eric Bailey 06a8a7efc2 intl pull and extract:all (#9704) 2026-01-15 10:58:01 -06:00
Samuel Newman 29f8bc998d Highlight first feed in sidebar if non selected (#9703) 2026-01-15 07:12:15 -08:00
Eric Bailey 4760e59ea4 Add back deleted css block (#9702) 2026-01-15 08:09:24 -06:00
Samuel Newman d9e37a222a scope last-selected-feed to per-account (#9500) 2026-01-15 06:07:09 -08:00
estrattonbailey 3b98f2f8ab Nightly source-language update 2026-01-15 03:06:32 +00:00
Samuel Newman ab85b509c0 Watermark posts in screenshots (#9637)
* watermark posts in screenshots

* disable when app is backgrounded

* fix alignment

* show watermark even when there isn't a button
2026-01-14 21:03:13 -06:00
Samuel Newman 65faee4f08 Unregister push token on signout (#8661)
* unregister push - WIP

* create agent and submit push token revokation

* mock unregisterpush

* fix import

* Add proxy headers

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-14 20:55:35 -06:00
pfrazee 94be661794 Nightly source-language update 2026-01-15 02:48:02 +00:00
Samuel Newman 10aa46aa9e Prevent flash of wrong theme on startup (web) (#9577)
* prevent flash of wrong theme on startup

* move bg color to #root

* Update and use existing system

* Darken slightly, better contrast

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-14 20:41:59 -06:00
Samuel Newman 8f82ad66df make button blue when tooltip is visible (#9668) 2026-01-14 20:34:59 -06:00
Samuel Newman 1cb362b596 Add loading="lazy" to expo-image on web (#9480)
* add `loading="lazy"` to expo-image

* add `loading="lazy"` to embed cards, avatars

* get rid of useless image wrapper indirection

* move image components to components dir

* fix imports

* fix import

* Keep avis eager

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-14 20:29:13 -06:00
Eric Bailey f960d2fbc6 fix: make logo show in qr code by absolutely positioning svg on top of it (#9373) (#9700)
* fix: make logo show in qr code by absolutely positioning svg on top of it

* fix: remove log and add explanation comment

* fix: only apply qrcode fix to web

Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
2026-01-14 20:28:48 -06:00
Samuel Newman 58ac9a3e7b Patch video player to add a ton of debug logging (#9657) 2026-01-14 19:18:29 -06:00
Eric Bailey bbab7695f9 Add some device location debug (#9693) 2026-01-14 19:05:33 -06:00
Samuel Newman 26aa7c0d9a Add cashtag support for stock ticker discussions (#9689)
* Add cashtag support for stock ticker discussions

Display cashtags ($TICKER format) as clickable links with dedicated menu actions and search feed, alongside hashtags. Includes composer highlighting and proper formatting.

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

* Fix cashtag search query to use # prefix

Cashtags need to be searched as "#$BTC" rather than just "$BTC" to work
with the search API.

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

* Update @atproto/api to 0.18.14 with cashtag support

The updated package includes native CASHTAG_REGEX detection for stock
ticker symbols like $AAPL and $BTC.

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

* Fix todo, remove redundant validation

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-14 19:03:48 -06:00
estrattonbailey 498a09e797 Nightly source-language update 2026-01-14 22:59:53 +00:00
Eric Bailey 2223babc2b [APP-1759] Live Now Open Beta (#9699)
* Add report dialog to LiveStatus dialog

* Mr Worldwide™

* Bug fix bug fix

* Copy update

* Simplify parsing

* Bump api sdk

* update dev-env

* Update yarn.lock

* Bump api sdk

* Refactor useActorStatus, add disablement and appeal dialog

* Fix types

* Guard against chat profile views

* Go live (disabled)

* Fix types

* Update config and gates

* Add allowed services

* Merge conflicts

* Fix gradient handling for nudge

* Only show nudge on your own profile

* Fix live avi overflow breakage

* Add TODO

* Feedback

* Update nux image

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-14 16:57:21 -06:00
Eric Bailey 5922c6622f [APP-1767] Live event feeds (#9696)
* Update env config for new worker setup

* Add liveEvents context

* Add live event feed card to Explore

* Add Discover banner/trending handling, device storage

* Add prefs methods, hook up to Discover banner

* Add settings toggle

* Fix up metrics and mutations

* Simplify undo logic

* Handle previews

* Update overlay color and text color handling

* Update metrics

* Handle live event in feed card

* Show multiple feeds on Explore for bsky team

* Fix dev/preview handling bug

* Add sidebar cards, update types

* Fix type error

* Fix type error

* Feedback

* Blurhash fix

* Copy update

* Dev only debug hook
2026-01-14 15:09:01 -06:00
Alex Benzer e684c21572 Improved client events for feed interactions (#9695) 2026-01-14 12:53:24 -08:00
Samuel Newman 226b19ae43 Reduce startup hang by not querying fonts (#9670)
* query fonts lazily

* use `lazy var`
2026-01-14 12:22:26 -08:00
Samuel Newman cb6e65e313 pin AsyncStorage to v2.2.0 (#9656) 2026-01-14 11:16:49 -08:00
Samuel Newman af92610864 Add comprehensive CLAUDE.md development guide (#9666)
* Add comprehensive CLAUDE.md development guide

Document the codebase architecture, styling system (ALF), component patterns
(Dialog, Menu, Button), i18n with Lingui, state management with TanStack Query,
and navigation patterns to help Claude work effectively in this codebase.

* Add footguns section to CLAUDE.md

Document critical pitfalls including:
- Dialog close callback pattern (control.close(() => ...)) for avoiding
  race conditions with navigation, state updates, and opening other dialogs
- Controlled vs uncontrolled input guidance
- Platform-specific component behavior differences

* Add React Compiler note to footguns section

Document that useMemo/useCallback are unnecessary since React Compiler
handles memoization automatically. Only use them for specific cases like
effect dependencies or non-React library interop.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 11:16:34 -08:00
pfrazee ca905eb3e1 Nightly source-language update 2026-01-14 02:52:08 +00:00
Samuel Newman 61c919110e Expose QueryClient for TanStack Query DevTools browser extension (#9678)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:56:06 -08:00
Eric Bailey 9aa360d328 Do not render links if uri is invalid (#9663)
* Do not render links if uri is invalid

* Update comment
2026-01-13 09:37:00 -06:00
Alex Benzer 2d8de1ddc3 Standardize metadata for client events in feeds (#9653) 2026-01-13 02:42:26 -08:00
Samuel Newman c540dae4e7 Set up conductor.json with yarn install and web server configuration (#9676)
Add conductor setup and run scripts that install dependencies and start the dev server with a dynamically allocated port.

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-10 01:52:59 -08:00
pfrazee 2818c7f508 Nightly source-language update 2026-01-10 02:43:34 +00:00
Alex Benzer c4fd9980cc Cleaner sidebar layout (#9603) 2026-01-10 00:13:43 +02:00
Samuel Newman 966ae68dd2 Set Claude model to Opus (#9672) 2026-01-09 08:45:38 -08:00
Samuel Newman d2aea9161b Delete claude-code-review.yml (#9671) 2026-01-09 08:35:38 -08:00
Eric Bailey edb19dd6cf [APP-1750] Add the ability to report livestreams (#9654)
* Add report dialog to LiveStatus dialog

* Mr Worldwide™

* Bug fix bug fix

* Copy update

* Simplify parsing

* Bump api sdk

* update dev-env

* Update yarn.lock

* Bump api sdk

* Refactor useActorStatus, add disablement and appeal dialog

* Fix types

* Guard against chat profile views

* Go live (disabled)

* Fix types

* Status reports should only go to bsky

* Ah yeah let's not override types

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-09 10:19:47 -06:00
Samuel Newman 85a616eee0 Add Claude Code GitHub Workflow (#9667)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-01-09 07:46:02 -08:00
pfrazee 7285157ab5 Nightly source-language update 2026-01-09 02:47:50 +00:00
Eric Bailey b3f775d1d8 [AAv2] Improve minimum age handling (#9650)
* Improve miminum age handling

* Update api sdk

* Fix bad import
2026-01-08 15:36:26 -06:00
Eric Bailey a1857d62bd Handle AA config failure (#9660)
* Clarify some comments

* Add error state in case of config failure

* Add retry button and relayout to accommodate
2026-01-08 14:35:43 -06:00
Samuel Newman d82592f072 Fix full-screen back gesture over PagerView on iOS 26 (#9659)
Patches react-native-pager-view to handle iOS 26's
interactiveContentPopGestureRecognizer, using the same logic that
already exists for RNSPanGestureRecognizer: on the leftmost page,
disable the scrollview's pan gesture to let the back gesture through.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:36:01 -08:00
Samuel Newman dae03312c9 Bump version to v1.114 (#9661) 2026-01-08 11:35:42 -08:00
Samuel Newman c2e5a18e42 Unify build concurrency to one build per platform (#9655)
Configure concurrency groups so only one iOS build and one Android build
can run at a time across all workflows. This prevents manual builds from
conflicting with automatic builds triggered by fingerprint changes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 07:35:13 -08:00
Samuel Newman 2efc047f64 bump version to v1.113.1 (#9652) 2026-01-07 13:15:55 -08:00
Samuel Newman 80049bbe82 exclude images from bundle size workflow (#9647) 2026-01-07 02:54:59 -08:00
Samuel Newman e627d37f42 [v1.113 prep] Translations (#9646)
* intl:pull

* yarn intl:extract:all
2026-01-07 01:14:48 -08:00
pfrazee 24fe45aaa1 Nightly source-language update 2026-01-07 02:48:01 +00:00
Samuel Newman 0deb9511af Enable lightbox for profile banner (#9643)
* allow pressing banner to view

* remove special type
2026-01-06 10:37:47 -08:00
Samuel Newman 43ebb80a5b patch in proguard rules (#9447) 2026-01-06 09:31:54 -08:00
Samuel Newman d91e206e21 Fix prefetching starter pack query (#9595)
* fix prefetching starter pack query

* add comment
2026-01-06 10:58:46 -06:00
Samuel Newman 493bd6dcf6 update crop tool, use new package name (#9642) 2026-01-06 18:29:37 +02:00
Samuel Newman 4f6ff6fc73 lazy load storybook (#9612) 2026-01-06 08:03:21 -08:00
estrattonbailey 7ba2997af5 Nightly source-language update 2026-01-06 00:18:33 +00:00
Samuel Newman 4b6fbcc050 fix suggested follow card size (#9638) 2026-01-05 09:36:54 -08:00
chuanchuan 55806f1087 fix(native): dismiss recommendation bottom sheet on profile navigation (#9608) 2026-01-02 02:02:26 -08:00
Samuel Newman 0aaf11aa7b fix drawer layout on mobile web (#9618) 2025-12-30 09:56:05 -08:00
Samuel Newman 9cfa7d0ba8 Remove RNGH from web bundle (#9528)
* remove RNGH on web

* add intentionally-failing webpack alias

* Update webpack.config.js
2025-12-30 03:40:19 -08:00
Samuel Newman 9743149c26 Replace graphemer with unicode-segmenter (#9526)
* replace graphemer with unicode-segmenter

* use grapheme entrypoint

* force resolution of unicode-segmenter
2025-12-30 03:39:09 -08:00
Samuel Newman 82877a096d Manually update source languages (#9611)
* manually update source langs

* add `intl:push-sources` script
2025-12-30 02:08:26 -08:00
Samuel Newman 12dc38c5fc replace lodash.isequal with fast-deep-equal (#9529) 2025-12-30 01:47:29 -08:00
Samuel Newman 7d5de60401 fix long handles in account switch (#8373) 2025-12-30 01:35:24 -08:00
Samuel Newman 8136258cd8 add pressed state to quote embed (#9592) 2025-12-30 01:31:09 -08:00
Samuel Newman afe5db8b57 clamp gif size (#9610) 2025-12-30 01:30:49 -08:00
Samuel Newman 893dd78a00 remove unused dep (#9606) 2025-12-30 01:23:48 -08:00
Eric Bailey b38013a12f Small copy tweaks (#9557) 2025-12-29 10:21:09 -08:00
Samuel Newman aa1395542f tweak styles of composer prompt (#9524) 2025-12-29 10:19:08 -08:00
Samuel Newman caa02db87b Update react native screens, remove patch (#9210)
* update react native screens

* bump rns

* see if nightly works

* update nightly

* yay it's out

* update react-nav

* update drawer layout and remove patch

* Update createNativeStackNavigatorWithAuth.tsx

* render drawer as "layout" component

* rm react-nav/drawer (unused)

* restore drawer layout patch
2025-12-29 10:16:49 -08:00
Alex Benzer f390167f66 Remove unwanted drop shadow on "see more" card on Android (#9584) 2025-12-29 02:00:29 -08:00
surfdude29 6275d2e0fd Temporarily use English for Esperanto intl-displaynames polyfill (#9574)
* use English for Esperanto `intl-displaynames` until bug is fixed

* Update src/locale/i18n.ts

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-12-29 01:38:40 -08:00
Alex Benzer 3e1e1ba00e Better copy for "must be signed in" message (#9602) 2025-12-29 01:38:04 -08:00
hailey 699fdbac01 update actions (#9586)
* update actions

* update actions

* $

* procenv
2025-12-22 19:21:17 -08:00
pfrazee a14a9be127 Nightly source-language update 2025-12-19 02:44:03 +00:00
Eric Bailey 998ee78e3d Move NUXDialogs into shell (#9573) 2025-12-18 12:08:17 -06:00
Eric Bailey a355eedf85 Improve instructions on NoAccessScreen and bday dialog (#9571) 2025-12-18 11:05:39 -06:00
Eric Bailey 89cffdf5d0 Ignore new flag SVGs in build output (#9567) 2025-12-18 09:45:51 -06:00
pfrazee e9524d8a83 Nightly source-language update 2025-12-18 02:42:38 +00:00
Eric Bailey f50b70e2b4 Bump to v1.113.0 (#9569) 2025-12-17 17:03:16 -06:00
Alex Benzer e80e2f66c3 Adds a dismiss button to user suggestions (#9484)
* Add dismiss button to user suggestions

* Adds dismiss button to suggested user cards, behind a feature gate

* Reverse gate check, best practice

* Sync DISMISS_ANIMATION_DURATION

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-12-17 15:45:21 -06:00
Samuel Newman f02b9c323f Change empty states if not own profile (#9518)
* change empty states if not own profile

* Update ProfileFeedgens.tsx

* capitalise starter packs
2025-12-17 14:10:39 -06:00
estrattonbailey 2200cc95d8 Nightly source-language update 2025-12-15 20:49:31 +00:00
Eric Bailey f45e58e057 [AAv2] Add blurb for orgs (#9556)
* Add blurb for orgs

* Update
2025-12-15 14:38:05 -06:00
Eric Bailey da87515d0a [APP-1684] Some contact import tweaks (#9555)
* Handle download link

* Improve NUX geo gating from #9549

* Fix alignment of phone code select

* Show full name

* Add gate to nux banner

* Add gate to settings screen

* Invert gate check in settings, whoops
2025-12-15 13:35:28 -06:00
surfdude29 348e7fa379 Restore notification count in BottomBar (#9554) 2025-12-15 09:15:52 -06:00
621 changed files with 111278 additions and 88345 deletions
+12 -2
View File
@@ -28,11 +28,21 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
#
#
# Bluesky's metrics API
EXPO_PUBLIC_METRICS_API_HOST=
# Growthbook config
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
# Sentry DSN for telemetry
EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
# geolocation web worker URL
GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
-126
View File
@@ -1,126 +0,0 @@
module.exports = {
root: true,
extends: [
'@react-native',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-native-a11y/ios',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'react',
'lingui',
'simple-import-sort',
'bsky-internal',
'eslint-plugin-react-compiler',
'import',
],
rules: {
'react/no-unescaped-entities': 0,
'react/prop-types': 0,
'react-native/no-inline-styles': 0,
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'bsky-internal/use-prefixed-imports': 'error',
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native priortized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
},
ignorePatterns: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party',
'ios',
'android',
'coverage',
'*.lock',
'.husky',
'patches',
'*.html',
'bskyweb',
'bskyembed',
'src/locale/locales/_build/',
'src/locale/locales/**/*.js',
'*.e2e.ts',
'*.e2e.tsx',
],
settings: {
componentWrapperFunctions: ['observer'],
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
}
+9 -2
View File
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
concurrency:
group: android-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -79,13 +82,15 @@ jobs:
echo "$json" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p android
--profile ${{ inputs.profile || 'testflight-android' }}
--profile $PROFILE
--local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
@@ -194,4 +199,6 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
+9 -2
View File
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
concurrency:
group: ios-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -91,13 +94,15 @@ jobs:
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p ios
--profile ${{ inputs.profile || 'testflight' }}
--profile $PROFILE
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
@@ -147,5 +152,7 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
+18 -9
View File
@@ -39,10 +39,12 @@ jobs:
# Validate the version if one is supplied. This should generally happen if the update is for a production client
- name: 🧐 Validate version
env:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
if: ${{ inputs.runtimeVersion }}
run: |
if [ -z "${{ inputs.runtimeVersion }}" ]; then
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
if [ -z "$RUNTIME_VERSION" ]; then
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
fi
- name: ⬇️ Checkout
@@ -103,12 +105,15 @@ jobs:
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
id: env
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
@@ -145,15 +150,15 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
group: ios-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -249,13 +254,15 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
group: android-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
@@ -363,5 +370,7 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
+54
View File
@@ -0,0 +1,54 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
+6 -6
View File
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go tooling
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version-file: bskyweb/go.mod
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -32,11 +32,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go tooling
uses: actions/setup-go@v3
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version-file: bskyweb/go.mod
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+7 -3
View File
@@ -32,7 +32,7 @@ jobs:
fi
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
if [[ "$COMMENT" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
@@ -75,6 +75,8 @@ jobs:
steps:
- name: Get PR HEAD SHA
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info
uses: actions/github-script@v7
with:
@@ -82,7 +84,7 @@ jobs:
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: ${{ github.event.issue.number }}
pull_number: process.env.ISSUE_NUMBER,
});
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
@@ -184,13 +186,15 @@ jobs:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
+12 -5
View File
@@ -35,20 +35,27 @@ jobs:
cache: yarn
- name: Ensure tracking relevant branches and checkout base
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
run: |
git checkout ${{ github.head_ref }}
git checkout ${{ github.base_ref }}
git checkout $HEAD_REF
git checkout $BASE_REF
- name: Get the base commit
id: base-commit
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
env:
BASE_REF: ${{ github.base_ref }}
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: Merge PR commit
env:
HEAD_REF: ${{ github.head_ref }}
run: |
# Have to set a git config for the merge to work
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit ${{ github.head_ref }}
git merge --no-edit $HEAD_REF
yarn install
yarn intl:build
@@ -84,7 +91,7 @@ jobs:
with:
base_path: "stats-base.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
+6 -2
View File
@@ -17,7 +17,9 @@ jobs:
fetch-depth: 0
- name: Fetch base branch
run: git fetch origin ${{ github.base_ref }} --depth=1
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- name: Install node
uses: actions/setup-node@v6
@@ -25,7 +27,9 @@ jobs:
node-version-file: .nvmrc
- name: Reset yarn.lock to base
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
- name: Yarn install
uses: Wandalen/wretry.action@master
+1
View File
@@ -124,3 +124,4 @@ bskyogcard/src/assets/fonts/noto-*
bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
+635
View File
@@ -0,0 +1,635 @@
# CLAUDE.md - Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
## Project Overview
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Custom design system called ALF (Application Layout Framework)
## Essential Commands
```bash
# Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
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
# Build
yarn build-web # Build web version
yarn prebuild # Generate native projects
```
## Project Structure
```
src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
│ └── shell/ # App shell (navigation bars, tabs)
├── state/
│ ├── queries/ # TanStack Query hooks
│ ├── preferences/ # User preferences (React Context)
│ ├── session/ # Authentication state
│ └── persisted/ # Persistent storage layer
├── lib/ # Utilities, constants, helpers
├── locale/ # i18n configuration and language files
└── Navigation.tsx # Main navigation configuration
```
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
### Basic Usage
```tsx
import {atoms as a, useTheme} from '#/alf'
function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
```
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints** - Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
### Naming Conventions
- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
## Component Patterns
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
```tsx
import * as Dialog from '#/components/Dialog'
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
```
### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
```tsx
import * as Menu from '#/components/Menu'
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
### Button Component
```tsx
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={XIcon} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
### TextField
```tsx
import * as TextField from '#/components/forms/TextField'
<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="you@example.com"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, Trans, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
const {_} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
// Pluralization
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
}))
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
```
**Commands:**
```bash
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
```
## State Management
### TanStack Query (Data Fetching)
```tsx
// 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]
// Query hook
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
// Mutation hook
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
},
onError: (error) => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
// XRPC APIs often have typed errors, allows nicer handling
} else {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
}
})
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
```tsx
export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: ['drafts'],
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: page => page.cursor,
})
}
```
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
### Preferences (React Context)
```tsx
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
```
### Session State
```tsx
import {useSession, useAgent} from '#/state/session'
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
## Navigation
Navigation uses React Navigation with type-safe route parameters.
```tsx
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
## Platform-Specific Code
Use file extensions for platform-specific implementations:
```
Component.tsx # Shared/default
Component.web.tsx # Web-only
Component.native.tsx # iOS + Android
Component.ios.tsx # iOS-only
Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
```tsx
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
import * as storage from '#/state/drafts/storage'
// WRONG - don't use require() or conditional imports for platform files
const storage = IS_NATIVE
? require('#/state/drafts/storage')
: require('#/state/drafts/storage.web')
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (IS_NATIVE) {
// Native-specific logic
}
```
## Import Aliases
Always use the `#/` alias for absolute imports:
```tsx
// Good
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
// Avoid
import {useSession} from '../../../state/session'
```
## Footguns
Common pitfalls to avoid in this codebase:
### Dialog Close Callback (Critical)
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
```tsx
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
const onConfirm = () => {
control.close(() => {
navigation.navigate('Home')
})
}
// CORRECT - opening another dialog after close
const onConfirm = () => {
control.close(() => {
otherDialogControl.open()
})
}
// CORRECT - state updates after close
const onConfirm = () => {
control.close(() => {
setSomeState(newValue)
onCallback?.()
})
}
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
Prefer `defaultValue` over `value` for TextInput on the old architecture:
```tsx
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
Always test on multiple platforms when using these components.
### React Compiler is Enabled
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
```tsx
// UNNECESSARY - React Compiler handles this
const handlePress = useCallback(() => {
doSomething()
}, [doSomething])
// JUST WRITE THIS
const handlePress = () => {
doSomething()
}
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
## Best Practices
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
4. **State**: Use TanStack Query for server state, React Context for UI preferences
5. **Components**: Check if a component exists in `#/components/` before creating new ones
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
7. **Testing**: Components should have `testID` props for E2E testing
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232025 Bluesky Social PBC
Copyright 20232026 Bluesky Social PBC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+41 -26
View File
@@ -1,5 +1,10 @@
// @ts-check
const pkg = require('./package.json')
/**
* @param {import('@expo/config-types').ExpoConfig} _config
* @returns {{ expo: import('@expo/config-types').ExpoConfig }}
*/
module.exports = function (_config) {
/**
* App version number. Should be incremented as part of a release cycle.
@@ -15,7 +20,7 @@ module.exports = function (_config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
const ASSOCIATED_DOMAINS = [
'applinks:bsky.app',
@@ -114,6 +119,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -192,10 +198,14 @@ module.exports = function (_config) {
scheme: 'https',
host: 'bsky.app',
},
IS_DEV && {
scheme: 'http',
host: 'localhost:19006',
},
...(IS_DEV
? [
{
scheme: 'http',
host: 'localhost:19006',
},
]
: []),
],
category: ['BROWSABLE', 'DEFAULT'],
},
@@ -227,20 +237,25 @@ module.exports = function (_config) {
'react-native-edge-to-edge',
{android: {enforceNavigationBarContrast: false}},
],
USE_SENTRY && [
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'app',
url: 'https://sentry.io',
},
],
...(USE_SENTRY
? [
/** @type {[string, any]} */ ([
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'app',
url: 'https://sentry.io',
},
]),
]
: []),
[
'expo-build-properties',
{
ios: {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
},
android: {
compileSdkVersion: 35,
@@ -297,25 +312,25 @@ module.exports = function (_config) {
'expo-splash-screen',
{
ios: {
enableFullScreenImage_legacy: true,
backgroundColor: '#ffffff',
image: './assets/splash.png',
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true,
backgroundColor: '#001429',
image: './assets/splash-dark.png',
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#0c7cff',
image: './assets/splash-android-icon.png',
imageWidth: 150,
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
dark: {
backgroundColor: '#0c2a49',
image: './assets/splash-android-icon-dark.png',
imageWidth: 150,
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
},
},
},
@@ -396,7 +411,7 @@ module.exports = function (_config) {
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
},
],
].filter(Boolean),
],
extra: {
eas: {
build: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z"/></svg>

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 563 KiB

-22
View File
@@ -1,22 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'simple-import-sort'],
extends: [
'eslint:recommended',
'preact',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}
+52
View File
@@ -0,0 +1,52 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import globals from 'globals'
export default tseslint.config(
// Global ignores
{
ignores: ['dist/**', 'node_modules/**'],
},
// Base JS recommended rules
js.configs.recommended,
// TypeScript rules with type checking
...tseslint.configs.recommendedTypeChecked,
// Main configuration
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'simple-import-sort': simpleImportSort,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
},
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
},
},
)
+9 -4
View File
@@ -7,7 +7,7 @@
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"lint": "eslint --cache src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -17,16 +17,21 @@
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^7.0.0",
"@eslint/js": "^9.18.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint": "^9.18.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.14.0",
"postcss": "^8.4.38",
"typescript-eslint": "^8.20.0",
"serve": "^14.2.5",
"tailwindcss": "^3.4.3",
"terser": "^5.43.1",
"typescript": "^5.8.3",
"vite": "^7.0.4",
"vite-tsconfig-paths": "^5.1.4"
},
"resolutions": {
"@types/estree": "1.0.6"
}
}
+308 -1350
View File
File diff suppressed because it is too large Load Diff
+45 -8
View File
@@ -37,14 +37,6 @@
font-style: italic;
font-display: swap;
}
html {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html {
background-color: black;
}
}
html,
body {
margin: 0px;
@@ -59,6 +51,19 @@
-ms-overflow-style: scrollbar;
font-synthesis-weight: none;
}
:root {
--text: black;
--background: white;
--backgroundLight: #e2e7ee;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--text: white;
--background: black;
--backgroundLight: #232e3e;
}
}
html,
body,
#root {
@@ -67,6 +72,32 @@
min-height: 100%;
width: 100%;
}
html.theme--light,
html.theme--light body,
html.theme--light #root {
background-color: white;
--text: black;
--background: white;
--backgroundLight: #DCE2EA;
}
html.theme--dark,
html.theme--dark body,
html.theme--dark #root {
color-scheme: dark;
background-color: black;
--text: white;
--background: black;
--backgroundLight: #232E3E;
}
html.theme--dim,
html.theme--dim body,
html.theme--dim #root {
color-scheme: dark;
background-color: #151D28;
--text: white;
--background: #151D28;
--backgroundLight: #2C3A4E;
}
#splash {
display: flex;
position: fixed;
@@ -93,6 +124,12 @@
overflow-y: scroll;
}
</style>
<script>
const theme = localStorage.getItem('ALF_THEME')
if (theme) {
document.documentElement.classList.add(`theme--${theme}`)
}
</script>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
+4 -4
View File
@@ -108,9 +108,9 @@
{% else %}
<meta property="og:title" content="@{{ profileHandle }}">
{% endif -%}
<meta name="description" content="This post requires authentication to view.">
<meta property="og:description" content="This post requires authentication to view.">
<meta property="twitter:description" content="This post requires authentication to view.">
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta name="twitter:card" content="summary">
{% endif -%}
{%- endblock %}
@@ -129,7 +129,7 @@
<div id="bsky_post_summary">
<h3>Post</h3>
<p id="bsky_handle">{{ profileHandle }}</p>
<p id="bsky_post_text">This post requires authentication to view.</p>
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
</div>
{% endif -%}
{%- endblock %}
+6
View File
@@ -0,0 +1,6 @@
{
"scripts": {
"setup": "yarn install",
"run": "yarn web --port $CONDUCTOR_PORT"
}
}
+273
View File
@@ -0,0 +1,273 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import { defineConfig } from 'eslint/config';
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
// @ts-expect-error no types
import reactNative from 'eslint-plugin-react-native'
// @ts-expect-error no types
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import importX from 'eslint-plugin-import-x'
import lingui from 'eslint-plugin-lingui'
import reactCompiler from 'eslint-plugin-react-compiler'
import bskyInternal from 'eslint-plugin-bsky-internal'
import globals from 'globals'
import tsParser from '@typescript-eslint/parser'
export default defineConfig(
/**
* Global ignores
*/
{
ignores: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party/**',
'ios/**',
'android/**',
'coverage/**',
'*.lock',
'.husky/**',
'patches/**',
'*.html',
'bskyweb/**',
'bskyembed/**',
'src/locale/locales/_build/**',
'src/locale/locales/**/*.js',
'*.e2e.ts',
'*.e2e.tsx',
'eslint.config.mjs',
],
},
/**
* Base configurations
*/
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'],
/**
* Main configuration for all JS/TS/JSX/TSX files
*/
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react,
'react-native': reactNative,
'react-native-a11y': reactNativeA11y,
'simple-import-sort': simpleImportSort,
lingui,
'react-compiler': reactCompiler,
'bsky-internal': bskyInternal,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
parser: tsParser,
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: 'detect',
},
componentWrapperFunctions: ['observer'],
},
rules: {
/**
* Custom rules
*/
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-prefixed-imports': 'error',
'bsky-internal/lingui-msg-rule': 'error',
/**
* React & React Native
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/immutability': 'warn',
/**
* Import sorting
*/
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native prioritized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
/**
* Import linting
*/
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
'import-x/no-unresolved': ['error', {
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
/**
* TypeScript-specific rules
*/
'no-unused-vars': 'off', // off, we use TS-specific rule below
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-expressions': ['error', {
allowTernary: true,
}],
/**
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
/**
* Turn off rules that we haven't enforced thus far
*/
'no-empty-pattern': 'off',
'no-async-promise-executor': 'off',
'no-constant-binary-expression': 'warn',
'prefer-const': 'off',
'no-empty': 'off',
'no-unsafe-optional-chaining': 'off',
'no-prototype-builtins': 'off',
'no-var': 'off',
'prefer-rest-params': 'off',
'no-case-declarations': 'off',
'no-irregular-whitespace': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-fallthrough': 'off',
'no-control-regex': 'off',
},
},
/**
* Test files configuration
*/
{
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
}
},
},
)
+9 -15
View File
@@ -1,14 +1,17 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const avoidUnwrappedText = require('../avoid-unwrapped-text')
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaFeatures: {
jsx: true,
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
ecmaVersion: 6,
sourceType: 'module',
},
})
@@ -773,15 +776,6 @@ function MyText({ foo }) {
errors: 1,
},
{
code: `
<View>
<Trans>{'foo'}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View prop={
+188
View File
@@ -0,0 +1,188 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const linguiMsgRule = require('../lingui-msg-rule')
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
},
})
describe('lingui-msg-rule', () => {
const tests = {
valid: [
// msg template literal
{
code: `
const {_} = useLingui()
const x = _(msg\`Hello\`)
`,
},
// msg template literal with interpolation
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
},
// plural macro
{
code: `
const {_} = useLingui()
const count = 5
const x = _(plural(count, {one: '# item', other: '# items'}))
`,
},
// select macro
{
code: `
const {_} = useLingui()
const gender = 'female'
const x = _(select(gender, {male: 'He', female: 'She', other: 'They'}))
`,
},
// selectOrdinal macro
{
code: `
const {_} = useLingui()
const position = 1
const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'}))
`,
},
// msg function call with object (descriptor form)
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello'}))
`,
},
// msg function call with object and context
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello', context: 'greeting'}))
`,
},
],
invalid: [
// Plain string literal (single quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _('Bad')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Plain string literal (double quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _("Bad")
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal without msg tag - with auto-fix
{
code: `
const {_} = useLingui()
const x = _(\`Bad\`)
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal with interpolation - with auto-fix
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(\`Hello \${name}\`)
`,
output: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// String with backticks that need escaping
{
code: `
const {_} = useLingui()
const x = _('Use \\\`code\\\` here')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Use \\\`code\\\` here\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Variable/identifier - no auto-fix possible
{
code: `
const {_} = useLingui()
const message = 'Hello'
const x = _(message)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Arbitrary function call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(getMessage())
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Empty call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _()
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
{
code: `
const {_} = useLingui()
const x = _(html\`Hello\`)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Number literal - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(123)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
],
}
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
})
+305 -277
View File
@@ -29,303 +29,331 @@ function getTagName(node) {
return reversedIdentifiers.reverse().join('.')
}
exports.create = function create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce text strings are wrapped in <Text> components',
},
schema: [
{
type: 'object',
properties: {
impliedTextComponents: {
type: 'array',
items: {type: 'string'},
},
impliedTextProps: {
type: 'array',
items: {type: 'string'},
},
suggestedTextWrappers: {
type: 'object',
additionalProperties: {type: 'string'},
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
function isTextComponent(tagName) {
return textComponents.includes(tagName) || tagName.endsWith('Text')
}
function isTextComponent(tagName) {
return textComponents.includes(tagName) || tagName.endsWith('Text')
}
return {
JSXText(node) {
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return {
JSXText(node) {
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
// We're good.
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this string in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
const message =
'Wrap this string in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
parent = parent.parent
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
return
}
parent = parent.parent
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
TemplateLiteral(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '_'
) {
// This is a user-facing string, keep going up.
parent = parent.parent
continue
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression' ||
parent.type === 'TaggedTemplateExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
JSXElement(node) {
if (getTagName(node) !== 'Trans') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for this JSXElement.
// TODO: Should nested <Trans> even be allowed?
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
// We're good.
return
parent = parent.parent
continue
}
const message =
'Wrap this <Trans> in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
// Be conservative for other types.
return
}
},
TemplateLiteral(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
ReturnStatement(node) {
let fnScope = context.getScope()
while (fnScope && fnScope.type !== 'function') {
fnScope = fnScope.upper
}
if (!fnScope) {
return
}
const fn = fnScope.block
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
return
}
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
return
}
if (!node.argument || node.argument.type !== 'JSXElement') {
return
}
const openingEl = node.argument.openingElement
if (openingEl.name.type !== 'JSXIdentifier') {
return
}
const returnedComponentName = openingEl.name.name
if (!isTextComponent(returnedComponentName)) {
context.report({
node,
message:
'Components ending with *Text must return <Text> or <SomeText>.',
})
}
},
}
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '_'
) {
// This is a user-facing string, keep going up.
parent = parent.parent
continue
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression' ||
parent.type === 'TaggedTemplateExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
JSXElement(node) {
if (getTagName(node) !== 'Trans') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for this JSXElement.
// TODO: Should nested <Trans> even be allowed?
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this <Trans> in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
ReturnStatement(node) {
let fnScope = context.sourceCode.getScope(node)
while (fnScope && fnScope.type !== 'function') {
fnScope = fnScope.upper
}
if (!fnScope) {
return
}
const fn = fnScope.block
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
return
}
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
return
}
if (!node.argument || node.argument.type !== 'JSXElement') {
return
}
const openingEl = node.argument.openingElement
if (openingEl.name.type !== 'JSXIdentifier') {
return
}
const returnedComponentName = openingEl.name.name
if (!isTextComponent(returnedComponentName)) {
context.report({
node,
message:
'Components ending with *Text must return <Text> or <SomeText>.',
})
}
},
}
},
}
+8 -2
View File
@@ -1,10 +1,16 @@
'use strict'
module.exports = {
const plugin = {
meta: {
name: 'eslint-plugin-bsky-internal',
version: '1.0.0',
},
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
'use-prefixed-imports': require('./use-prefixed-imports'),
'lingui-msg-rule': require('./lingui-msg-rule'),
},
}
module.exports = plugin
+110
View File
@@ -0,0 +1,110 @@
'use strict'
/**
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros',
recommended: true,
},
fixable: 'code',
messages: {
missingMsg:
'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)',
},
schema: [],
},
create(context) {
// Valid Lingui macro functions that can be passed to _()
const VALID_MACRO_FUNCTIONS = new Set([
'msg',
'plural',
'select',
'selectOrdinal',
])
/**
* Escape backticks and backslashes for template literal
*/
function escapeForTemplateLiteral(str) {
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
}
/**
* Try to get a fixer for the given argument
* Returns null if we can't safely fix it
*/
function getFixer(firstArg) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
// Fix string literals: _('foo') -> _(msg`foo`)
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
const escaped = escapeForTemplateLiteral(firstArg.value)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
}
}
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
if (firstArg.type === 'TemplateLiteral') {
const text = sourceCode.getText(firstArg)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg' + text)
}
}
return null
}
return {
CallExpression(node) {
// Check if this is a call to _()
if (node.callee.type !== 'Identifier' || node.callee.name !== '_') {
return
}
// Must have at least one argument
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'missingMsg',
})
return
}
const firstArg = node.arguments[0]
// Valid: _(msg`...`)
if (
firstArg.type === 'TaggedTemplateExpression' &&
firstArg.tag.type === 'Identifier' &&
firstArg.tag.name === 'msg'
) {
return
}
// Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
if (
firstArg.type === 'CallExpression' &&
firstArg.callee.type === 'Identifier' &&
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
) {
return
}
// Everything else is invalid
const fix = getFixer(firstArg)
context.report({
node,
messageId: 'missingMsg',
fix,
})
},
}
},
}
+24 -15
View File
@@ -3,20 +3,29 @@ const BANNED_IMPORTS = [
'@fortawesome/free-solid-svg-icons',
]
exports.create = function create(context) {
return {
ImportDeclaration(node) {
const source = node.source
if (typeof source.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(source.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Prevent importing entire icon packages',
},
}
schema: [],
},
create(context) {
return {
ImportDeclaration(node) {
const source = node.source
if (typeof source.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(source.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
},
}
},
}
+4
View File
@@ -13,7 +13,11 @@ const BANNED_IMPORT_PREFIXES = [
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce using prefixed imports for internal paths',
},
fixable: 'code',
schema: [],
},
create(context) {
return {
-31
View File
@@ -1,31 +0,0 @@
'use strict'
exports.create = function create(context) {
return {
ImportSpecifier(node) {
if (
!node.local ||
node.local.type !== 'Identifier' ||
node.local.name !== 'useGate'
) {
return
}
if (
node.parent.type !== 'ImportDeclaration' ||
!node.parent.source ||
node.parent.source.type !== 'Literal'
) {
return
}
const source = node.parent.source.value
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
}
// TODO: Verify gate() call results aren't stored in variables.
},
}
}
+1 -12
View File
@@ -99,20 +99,9 @@ jest.mock('expo-modules-core', () => ({
requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null
}),
createPermissionHook: () => () => [true],
}))
jest.mock('expo-localization', () => ({
getLocales: () => [],
}))
jest.mock('statsig-react-native-expo', () => ({
Statsig: {
initialize() {},
initializeCalled() {
return false
},
},
}))
jest.mock('../src/logger/bitdrift/lib', () => ({}))
jest.mock('../src/lib/statsig/statsig', () => ({}))
@@ -11,7 +11,7 @@ import {
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {isIOS} from '#/platform/detection'
import {IS_IOS} from '#/env'
import {
type BottomSheetState,
type BottomSheetViewProps,
@@ -30,7 +30,7 @@ const NativeView: React.ComponentType<
const NativeModule = requireNativeModule('BottomSheet')
const isIOS15 =
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
@@ -91,7 +91,7 @@ export class BottomSheetNativeComponent extends React.Component<
}
let extraStyles
if (isIOS15 && this.state.viewHeight) {
if (IS_IOS15 && this.state.viewHeight) {
const {viewHeight} = this.state
const cornerRadius = this.props.cornerRadius ?? 0
if (viewHeight < screenHeight / 2) {
@@ -112,7 +112,7 @@ export class BottomSheetNativeComponent extends React.Component<
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
if (isIOS15) {
if (IS_IOS15) {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
@@ -153,7 +153,7 @@ function BottomSheetNativeComponentInner({
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
return (
<NativeView
+35 -28
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.112.0",
"version": "1.117.0",
"private": true,
"engines": {
"node": ">=20"
@@ -41,15 +41,15 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start",
"e2e:run": "maestro test __e2e__",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
"e2e:run": "maestro test",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
@@ -62,6 +62,7 @@
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
@@ -72,10 +73,11 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.8",
"@atproto/api": "^0.18.20",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -91,6 +93,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook-react": "^1.6.2",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
@@ -98,10 +101,9 @@
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.3.13",
"@react-navigation/drawer": "^7.3.12",
"@react-navigation/native": "^7.1.9",
"@react-navigation/native-stack": "^7.3.13",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "5.25.0",
@@ -145,7 +147,6 @@
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.9",
"expo-intent-launcher": "~13.0.8",
@@ -156,6 +157,7 @@
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.14",
"expo-privacy-sensitive": "^0.1.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
@@ -164,17 +166,19 @@
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"multiformats": "9.9.0",
@@ -194,7 +198,7 @@
"react-native-compressor": "^1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.1.8",
"react-native-drawer-layout": "^4.2.1",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
@@ -204,7 +208,7 @@
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-screens": "^4.19.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
@@ -218,17 +222,18 @@
"react-textarea-autosize": "^8.5.3",
"sonner": "^2.0.7",
"sonner-native": "^0.21.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"tldts": "^6.1.46",
"unicode-segmenter": "^0.14.5",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.196",
"@atproto/dev-env": "^0.3.208",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@eslint/js": "^9.39.2",
"@expo/config-plugins": "~54.0.1",
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
@@ -242,28 +247,28 @@
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^29.7.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-preset-expo": "~54.0.0",
"eslint": "^8.19.0",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lingui": "^0.11.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
@@ -271,7 +276,6 @@
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^3.6.0",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
@@ -279,16 +283,19 @@
"ts-node": "^10.9.1",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.53.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4",
"**/zod": "3.23.8",
"**/multiformats": "9.9.0"
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6"
},
"jest": {
"preset": "jest-expo/ios",
+136
View File
@@ -0,0 +1,136 @@
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
index b988d3f..7743421 100644
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
@@ -36,6 +36,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ consumerProguardFiles 'proguard-rules.pro'
}
lintOptions {
abortOnError false
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
new file mode 100644
index 0000000..3b5b864
--- /dev/null
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
+16
View File
@@ -0,0 +1,16 @@
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
index 183480f..7b64f6e 100644
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
@@ -2,10 +2,9 @@ import ExpoModulesCore
public final class FontLoaderModule: Module {
// could be a Set, but to be able to pass to JS we keep it as an array
- private var registeredFonts: [String]
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
public required init(appContext: AppContext) {
- self.registeredFonts = queryCustomNativeFonts()
super.init(appContext: appContext)
}
+103
View File
@@ -0,0 +1,103 @@
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
index 022ae48..416504f 100644
--- a/node_modules/expo-image/build/Image.types.d.ts
+++ b/node_modules/expo-image/build/Image.types.d.ts
@@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
* @default 'normal'
*/
priority?: 'low' | 'normal' | 'high' | null;
+ /**
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
+ *
+ * - `'lazy'` - Defers loading until the image is near the viewport.
+ * - `'eager'` - Loads the image immediately.
+ *
+ * @default undefined
+ * @platform web
+ */
+ loading?: 'lazy' | 'eager' | null;
/**
* Determines whether to cache the image and where: on the disk, in the memory or both.
*
diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx
index 2a49ff0..1c3de93 100644
--- a/node_modules/expo-image/src/ExpoImage.web.tsx
+++ b/node_modules/expo-image/src/ExpoImage.web.tsx
@@ -70,6 +70,7 @@ export default function ExpoImage({
onLoadEnd,
onDisplay,
priority,
+ loading,
blurRadius,
recyclingKey,
style,
@@ -118,6 +119,7 @@ export default function ExpoImage({
accessibilityLabel={accessibilityLabel ?? alt}
cachePolicy={cachePolicy}
priority={priority}
+ loading={loading}
tintColor={tintColor}
/>
),
@@ -149,6 +151,7 @@ export default function ExpoImage({
className={className}
cachePolicy={cachePolicy}
priority={priority}
+ loading={loading}
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
hashPlaceholderContentPosition={contentPosition}
hashPlaceholderStyle={imageHashStyle}
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
index 9dec0e7..61c1621 100644
--- a/node_modules/expo-image/src/Image.types.ts
+++ b/node_modules/expo-image/src/Image.types.ts
@@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
*/
priority?: 'low' | 'normal' | 'high' | null;
+ /**
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
+ *
+ * - `'lazy'` - Defers loading until the image is near the viewport.
+ * - `'eager'` - Loads the image immediately.
+ *
+ * @default undefined
+ * @platform web
+ */
+ loading?: 'lazy' | 'eager' | null;
+
/**
* Determines whether to cache the image and where: on the disk, in the memory or both.
*
diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx
index e8f891d..89a5cb1 100644
--- a/node_modules/expo-image/src/web/ImageWrapper.tsx
+++ b/node_modules/expo-image/src/web/ImageWrapper.tsx
@@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef(
contentPosition,
hashPlaceholderContentPosition,
priority,
+ loading,
style,
hashPlaceholderStyle,
tintColor,
@@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef(
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
+ loading={loading || undefined}
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
{...getImgPropsFromSource(source)}
{...props}
diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts
index 19bbe2f..179837f 100644
--- a/node_modules/expo-image/src/web/ImageWrapper.types.ts
+++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts
@@ -29,6 +29,7 @@ export type ImageWrapperProps = {
contentPosition?: ImageContentPositionObject;
hashPlaceholderContentPosition?: ImageContentPositionObject;
priority?: string | null;
+ loading?: 'lazy' | 'eager' | null;
style: CSSProperties;
tintColor?: string | null;
hashPlaceholderStyle?: CSSProperties;
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
index efa71f9..a23c624 100644
index 40fdcdb..770fd67 100644
--- a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
+++ b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
@@ -124,15 +124,21 @@ export function Drawer({
@@ -31,7 +31,7 @@ index efa71f9..a23c624 100644
- }, finished => runOnJS(onAnimationEnd)(open, finished));
+ }, finished => {
+ animatingTo.value = null;
+ runOnJS(onAnimationEnd)(open, finished)
+ runOnJS(onAnimationEnd)(open, finished);
+ });
if (open) {
runOnJS(onOpen)();
@@ -0,0 +1,30 @@
diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m
index adfc7c6..366df60 100644
--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m
+++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m
@@ -498,6 +498,25 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
return YES;
}
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
+ if (@available(iOS 26, *)) {
+ if (gestureRecognizer == self.panGestureRecognizer &&
+ otherGestureRecognizer == self.reactViewController.navigationController.interactiveContentPopGestureRecognizer) {
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
+ BOOL isLTR = [self isLtrLayout];
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
+
+ if (self.currentIndex == 0 && isBackGesture) {
+ self.scrollView.panGestureRecognizer.enabled = false;
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+
+ return YES;
+ }
+ }
+
self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
return NO;
}
@@ -0,0 +1,11 @@
# react-native-pager-view+6.8.0.patch
Adds support for iOS 26's `interactiveContentPopGestureRecognizer` (full-screen back gesture).
The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custom full-screen gesture for pre-iOS 26) in `shouldRecognizeSimultaneouslyWithGestureRecognizer:`. It checks if the user is on the leftmost page and swiping right - if so, it disables the scrollview's pan gesture to let the back gesture through.
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
Related issues:
- https://github.com/software-mansion/react-native-screens/issues/3512
- https://github.com/software-mansion/react-native-screens/pull/3420
-326
View File
@@ -1,326 +0,0 @@
diff --git a/node_modules/react-native-screens/ios/RNSScreen.mm b/node_modules/react-native-screens/ios/RNSScreen.mm
index b62a2e2..cb469db 100644
--- a/node_modules/react-native-screens/ios/RNSScreen.mm
+++ b/node_modules/react-native-screens/ios/RNSScreen.mm
@@ -729,9 +729,26 @@ - (void)notifyTransitionProgress:(double)progress closing:(BOOL)closing goingFor
#endif
}
-#if !RCT_NEW_ARCH_ENABLED
+- (void)willMoveToWindow:(UIWindow *)newWindow
+{
+ if (@available(iOS 26, *)) {
+ // In iOS 26, as soon as another screen appears in transition, it is interactable
+ // To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions
+ // Disabling interactions for parent navigation controller won't be enough in case of nested stack
+ // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy
+ // To be sure, we block interactions on the whole window.
+ // Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly
+ newWindow.userInteractionEnabled = false;
+ }
+}
+
- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow
+ presentationController.containerView.window.userInteractionEnabled = false;
+ }
+#if !RCT_NEW_ARCH_ENABLED
// On Paper, we need to call both "cancel" and "reset" here because RN's gesture
// recognizer does not handle the scenario when it gets cancelled by other top
// level gesture recognizer. In this case by the modal dismiss gesture.
@@ -744,8 +761,8 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati
// down.
[_touchHandler cancel];
[_touchHandler reset];
-}
#endif // !RCT_NEW_ARCH_ENABLED
+}
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
{
@@ -757,6 +774,10 @@ - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presenta
- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions; see presentationControllerWillDismiss
+ presentationController.containerView.window.userInteractionEnabled = true;
+ }
// NOTE(kkafar): We should consider depracating the use of gesture cancel here & align
// with usePreventRemove API of react-navigation v7.
[self notifyGestureCancel];
@@ -767,6 +788,11 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr
- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions; see presentationControllerWillDismiss
+ // Dismissed screen doesn't hold a reference to window, but presentingViewController.view does
+ presentationController.presentingViewController.view.window.userInteractionEnabled = true;
+ }
if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) {
[_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController];
}
@@ -1518,6 +1544,10 @@ - (void)viewWillDisappear:(BOOL)animated
- (void)viewDidAppear:(BOOL)animated
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions, see willMoveToWindow
+ self.view.window.userInteractionEnabled = true;
+ }
[super viewDidAppear:animated];
if (!_isSwiping || _shouldNotify) {
// we are going forward or dismissing without swipe
diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
index 229dc58..10b365b 100644
--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
@@ -62,26 +62,6 @@ @interface RNSScreenStackView () <
@implementation RNSNavigationController
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-- (void)viewDidLoad
-{
- // iOS 26 introduces new gesture recognizer which replaces our RNSPanGestureRecognizer.
- // The problem is that we are not able to handle it here for various reasons:
- // - the new recognizer comes with its own delegate and our current approach is to wire
- // all recognizers to RNSScreenStackView; to be 100% sure we don't break the logic,
- // we would have to decorate its delegate and call it after our code, which would
- // break other recognizers that the stack view is the delegate for
- // - when RNSScreenStackView.setupGestureHandler method is called, the recognizer hasn't been
- // loaded yet and there is no other place to configure in a not "hacky" way
- // - the official docs warn us to not use it for anything other than "setting up failure requirements with it"
- // - we expose fullScreenGestureEnabled prop to enable/disable the feature,
- // so we need control over the delegate
- if (@available(iOS 26.0, *)) {
- self.interactiveContentPopGestureRecognizer.enabled = NO;
- }
-}
-#endif // iOS 26
-
#if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle
{
@@ -219,50 +199,6 @@ - (bool)onRepeatedTabSelectionOfTabScreenController:(RNSTabsScreenViewController
return false;
}
-#pragma mark - UINavigationBarDelegate
-
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
-{
- if (@available(iOS 26, *)) {
- // To prevent popping multiple screens when back button is pressed repeatedly,
- // We allow for pop operation to proceed only if no transition is in progress,
- // which we check indirectly by checking if transitionCoordinator is set.
- // If it's not, we are safe to proceed.
- if (self.transitionCoordinator == nil) {
- // We still need to disable interactions for back button so click effects are not applied,
- // and there is unfortunately no better place for it currently
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
- if (button != nil) {
- button.userInteractionEnabled = false;
- }
-
- return true;
- }
-
- return false;
- }
-
- return true;
-}
-
-- (void)navigationBar:(UINavigationBar *)navigationBar didPopItem:(UINavigationItem *)item
-{
- if (@available(iOS 26, *)) {
- // Reset interactions on back button -> see navigationBar:shouldPopItem
- // IMPORTANT: This reset won't execute when preventNativeDismiss is on.
- // However, on iOS 26, unlike in previous versions, the back button instance changes
- // when handling preventNativeDismiss and userIteractionEnabled is reset.
- // The instance also changes when regular screen pop happens, but in that case
- // the value of userInteractionEnabled is carried on, and we reset it here.
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
- if (button != nil) {
- button.userInteractionEnabled = true;
- }
- }
-}
-#endif // Check for iOS >= 26
-
#pragma mark - RNSFrameCorrectionProvider
#ifdef RNS_GAMMA_ENABLED
@@ -327,7 +263,7 @@ @implementation RNSScreenStackView {
UINavigationController *_controller;
NSMutableArray<RNSScreenView *> *_reactSubviews;
BOOL _invalidated;
- BOOL _isFullWidthSwiping;
+ BOOL _isFullWidthSwipingWithPanGesture; // used only for content swipe with RNSPanGestureRecognizer
RNSPercentDrivenInteractiveTransition *_interactionController;
__weak RNSScreenStackManager *_manager;
BOOL _updateScheduled;
@@ -522,6 +458,11 @@ - (void)reactAddControllerToClosestParent:(UIViewController *)controller
[self addSubview:controller.view];
#if !TARGET_OS_TV
_controller.interactivePopGestureRecognizer.delegate = self;
+ #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26, *)) {
+ _controller.interactiveContentPopGestureRecognizer.delegate = self;
+ }
+#endif // Check for iOS >= 26.0
#endif
[controller didMoveToParentViewController:parentView.reactViewController];
// On iOS pre 12 we observed that `willShowViewController` delegate method does not always
@@ -943,7 +884,7 @@ - (void)dismissOnReload
// when preventing the native dismiss with back button, we have to return the animator.
// Also, we need to return the animator when full width swiping even if the animation is not custom,
// otherwise the screen will be just popped immediately due to no animation
- ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwiping ||
+ ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwipingWithPanGesture ||
[RNSScreenStackAnimator isCustomAnimation:screen.stackAnimation] || _customAnimation)) {
return [[RNSScreenStackAnimator alloc] initWithOperation:operation];
}
@@ -967,23 +908,39 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
}
RNSScreenView *topScreen = _reactSubviews.lastObject;
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
+
#if TARGET_OS_TV || TARGET_OS_VISION
[self cancelTouchesInParent];
return YES;
#else
- // RNSPanGestureRecognizer will receive events iff topScreen.fullScreenSwipeEnabled == YES;
- // Events are filtered in gestureRecognizer:shouldReceivePressOrTouchEvent: method
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
- if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
- _isFullWidthSwiping = YES;
- [self cancelTouchesInParent];
- return YES;
+ // On iOS < 26, we have a custom full screen swipe recognizer that functions similarily
+ // to interactiveContentPopGestureRecognizer introduced in iOS 26.
+ // On iOS >= 26, we want to use the native one, but we are unable to handle custom animations
+ // with native interactiveContentPopGestureRecognizer, so we have to fallback to the old implementation.
+ // In this case, the old one should behave as close as the new native one, having only the difference
+ // in animation, and without any customization that is exclusive for it (e.g. gestureResponseDistance).
+ if (@available(iOS 26, *)) {
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
+ _isFullWidthSwipingWithPanGesture = YES;
+ [self cancelTouchesInParent];
+ return YES;
+ }
+ return NO;
+ } else {
+ if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
+ _isFullWidthSwipingWithPanGesture = YES;
+ [self cancelTouchesInParent];
+ return YES;
+ }
+ return NO;
}
- return NO;
}
// Now we're dealing with RNSScreenEdgeGestureRecognizer (or _UIParallaxTransitionPanGestureRecognizer)
- if (topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation]) {
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) {
UIRectEdge edges = ((RNSScreenEdgeGestureRecognizer *)gestureRecognizer).edges;
BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft;
@@ -1028,7 +985,9 @@ - (void)setupGestureHandlers
rightEdgeSwipeGestureRecognizer.delegate = self;
[self addGestureRecognizer:rightEdgeSwipeGestureRecognizer];
- // gesture recognizer for full width swipe gesture
+ // Starting from iOS 26, RNSPanGestureRecognizer has been mostly replaced by native
+ // interactiveContentPopGestureRecognizer. It still needs to handle custom dismiss animations,
+ // which we are not able to handle with the latter.
RNSPanGestureRecognizer *panRecognizer = [[RNSPanGestureRecognizer alloc] initWithTarget:self
action:@selector(handleSwipe:)];
panRecognizer.delegate = self;
@@ -1091,7 +1050,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
[_interactionController cancelInteractiveTransition];
}
_interactionController = nil;
- _isFullWidthSwiping = NO;
+ _isFullWidthSwipingWithPanGesture = NO;
}
default: {
break;
@@ -1225,14 +1184,6 @@ - (BOOL)isScrollViewPanGestureRecognizer:(UIGestureRecognizer *)gestureRecognize
// Be careful when adding another type of gesture recognizer.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePressOrTouchEvent:(NSObject *)event
{
- if (@available(iOS 26, *)) {
- // in iOS 26, you can swipe to pop screen before the previous one finished transitioning;
- // this prevents from registering the second gesture
- if ([self isTransitionInProgress]) {
- return NO;
- }
- }
-
RNSScreenView *topScreen = _reactSubviews.lastObject;
for (RNSScreenView *s in _reactSubviews.reverseObjectEnumerator) {
@@ -1249,10 +1200,30 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
return NO;
}
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26, *)) {
+ // On iOS 26, fullScreenSwipeEnabled takes no effect, and depending on whether custom animations are on,
+ // we select either interactiveContentPopGestureRecognizer or RNSPanGestureRecognizer
+ if (([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]] &&
+ !customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) ||
+ (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
+ customAnimationOnSwipePropSetAndSelectedAnimationIsCustom)) {
+ return NO;
+ }
+ } else {
+ // We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
+ if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
+ return topScreen.fullScreenSwipeEnabled;
+ }
+ }
+#else // check for iOS >= 26
// We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
return topScreen.fullScreenSwipeEnabled;
}
+#endif // check for iOS >= 26
// RNSScreenEdgeGestureRecognizer || _UIParallaxTransitionPanGestureRecognizer
return YES;
@@ -1268,15 +1239,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:touch];
}
-- (BOOL)isTransitionInProgress
-{
- if (_controller.transitionCoordinator != nil) {
- return YES;
- }
-
- return NO;
-}
-
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
@@ -1289,7 +1251,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
if (gestureRecognizer.state == UIGestureRecognizerStateBegan || isBackGesture) {
return NO;
}
-
return YES;
}
return NO;
+97 -84
View File
@@ -1,5 +1,4 @@
import '#/logger/sentry/setup'
import '#/logger/bitdrift/setup'
import '#/view/icons'
import React, {useEffect, useState} from 'react'
@@ -18,12 +17,10 @@ import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -61,25 +58,37 @@ import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
if (IS_IOS) {
SystemUI.setBackgroundColorAsync('black')
}
if (isAndroid) {
if (IS_ANDROID) {
// iOS is handled by the config plugin -sfn
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
@@ -93,6 +102,7 @@ if (isAndroid) {
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -109,7 +119,7 @@ function InnerApp() {
if (account) {
await resumeSession(account)
} else {
await tryFetchGates(undefined, 'prefer-fresh-gates')
await features.init
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -139,56 +149,57 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</VideoVolumeProvider>
</Splash>
@@ -202,7 +213,7 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
@@ -220,30 +231,32 @@ function App() {
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
+86 -70
View File
@@ -9,7 +9,6 @@ import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
@@ -48,7 +47,6 @@ import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -56,8 +54,20 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -68,6 +78,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -83,6 +94,8 @@ function InnerApp() {
try {
if (account) {
await resumeSession(account)
} else {
await features.init
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
@@ -115,52 +128,53 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
@@ -174,7 +188,7 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
@@ -191,25 +205,27 @@ function App() {
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</Geo.Provider>
+87 -91
View File
@@ -28,7 +28,7 @@ import {
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util'
import {useCallOnce} from '#/lib/once'
import {buildStateObject} from '#/lib/routes/helpers'
import {
type AllNavigatorParams,
@@ -38,13 +38,11 @@ import {
type MessagesTabNavigatorParams,
type MyProfileTabNavigatorParams,
type NotificationsTabNavigatorParams,
type RouteParams,
type SearchTabNavigatorParams,
type State,
} from '#/lib/routes/types'
import {type RouteParams, type State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -68,7 +66,7 @@ import {PostThreadScreen} from '#/view/screens/PostThread'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {Storybook} from '#/view/screens/Storybook'
import {StorybookScreen} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
@@ -138,6 +136,9 @@ import {
EmailDialogScreenID,
useEmailDialogControl,
} from '#/components/dialogs/EmailDialog'
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -304,7 +305,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
/>
<Stack.Screen
name="Debug"
getComponent={() => Storybook}
getComponent={() => StorybookScreen}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
@@ -636,7 +637,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
* The TabsNavigator is used by native mobile to represent the routes
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator() {
function TabsNavigator({
layout,
}: {
layout: React.ComponentProps<typeof Tab.Navigator>['layout']
}) {
const tabBar = useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
@@ -649,7 +654,8 @@ function TabsNavigator() {
initialRouteName="HomeTab"
backBehavior="initialRoute"
screenOptions={{headerShown: false, lazy: true}}
tabBar={tabBar}>
tabBar={tabBar}
layout={layout}>
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
<Tab.Screen
@@ -757,7 +763,11 @@ function MessagesTabNavigator() {
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = () => {
const FlatNavigator = ({
layout,
}: {
layout: React.ComponentProps<typeof Flat.Navigator>['layout']
}) => {
const t = useTheme()
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
@@ -765,6 +775,7 @@ const FlatNavigator = () => {
return (
<Flat.Navigator
layout={layout}
screenListeners={screenListeners}
screenOptions={screenOptions(t)}>
<Flat.Screen
@@ -832,11 +843,11 @@ const LINKING = {
// native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
// since it will be created by react-navigation.
if (path.includes('intent/')) {
if (isNative) return
if (IS_NATIVE) return
return buildStateObject('Flat', 'Home', params)
}
if (isNative) {
if (IS_NATIVE) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
}
@@ -869,11 +880,13 @@ const LINKING = {
let lastHandledNotificationDateDedupe: number | undefined
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined)
const previousScreen = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements()
@@ -911,7 +924,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
)
async function handlePushNotificationEntry() {
if (!isNative) return
if (!IS_NATIVE) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
@@ -935,11 +948,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const payload = getNotificationPayload(response.notification)
if (payload) {
notyLogger.metric(
'notifications:openApp',
{reason: payload.reason, causedBoot: true},
{statsig: false},
)
ax.metric('notifications:openApp', {
reason: payload.reason,
causedBoot: true,
})
if (payload.reason === 'chat-message') {
handleChatMessage(payload)
@@ -963,47 +975,69 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
}
}
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
const onNavigationReady = useCallOnce(() => {
const currentScreen = getCurrentRouteName()
setNavigationMetadata({
previousScreen: currentScreen,
currentScreen,
})
previousScreen.current = currentScreen
handlePushNotificationEntry()
ax.metric('router:navigate', {})
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
emailDialogControl.open({
id: EmailDialogScreenID.VerificationReminder,
})
snoozeEmailConfirmationPrompt()
}
}
ax.metric('init', {
initMs: Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
),
})
if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
})
return (
<>
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
logger.metric(
'router:navigate',
{from: prevLoggedRouteName.current},
{statsig: false},
)
prevLoggedRouteName.current = getCurrentRouteName()
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logger.metric('router:navigate', {}, {statsig: false})
handlePushNotificationEntry()
}}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn
navigationInChildEnabled>
{children}
</NavigationContainer>
</>
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
const currentScreen = getCurrentRouteName()
// do this before metric
setNavigationMetadata({
previousScreen: previousScreen.current,
currentScreen,
})
ax.metric('router:navigate', {from: previousScreen.current})
previousScreen.current = currentScreen
}}
onReady={onNavigationReady}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn
navigationInChildEnabled>
{children}
</NavigationContainer>
)
}
@@ -1059,7 +1093,7 @@ function reset(): Promise<void> {
navigationRef.dispatch(
CommonActions.reset({
index: 0,
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
routes: [{name: IS_NATIVE ? 'HomeTab' : 'Home'}],
}),
)
return Promise.race([
@@ -1077,44 +1111,6 @@ function reset(): Promise<void> {
}
}
let didInit = false
function logModuleInitTime() {
if (didInit) {
return
}
didInit = true
const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
)
console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', {
initMs,
})
if (isWeb) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
// Relies on our patch to polyfill.js in metro-runtime
const initLogs = (global as any).__INIT_LOGS__
if (shouldLog && Array.isArray(initLogs)) {
console.log(initLogs.join('\n'))
}
}
}
export {
FlatNavigator,
navigate,
+4 -3
View File
@@ -21,9 +21,9 @@ import * as SplashScreen from 'expo-splash-screen'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
import splashImagePointer from '../assets/splash.png'
import splashImagePointer from '../assets/splash/splash.png'
// @ts-ignore
import darkSplashImagePointer from '../assets/splash-dark.png'
import darkSplashImagePointer from '../assets/splash/splash-dark.png'
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
@@ -146,7 +146,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
() => {
'worklet'
// set these values to check animation at specific point
outroLogo.set(() =>
withTiming(
+51 -41
View File
@@ -9,12 +9,8 @@ import {
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {isAppPassword} from '#/lib/jwt'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSession, useSessionApi} from '#/state/session'
import {useSessionApi} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -30,7 +26,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Outlet as PortalOutlet} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
@@ -39,6 +35,8 @@ import {
isLegacyBirthdateBug,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {useDeviceGeolocationApi} from '#/geolocation'
const textStyles = [a.text_md, a.leading_snug]
@@ -46,6 +44,7 @@ const textStyles = [a.text_md, a.leading_snug]
export function NoAccessScreen() {
const t = useTheme()
const {_} = useLingui()
const ax = useAnalytics()
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
const birthdateControl = useDialogControl()
@@ -55,9 +54,6 @@ export function NoAccessScreen() {
const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const {currentAccount} = useSession()
const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '')
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
@@ -67,8 +63,8 @@ export function NoAccessScreen() {
useEffect(() => {
// just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {})
logger.metric(`ageAssurance:noAccessScreen:shown`, {
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
@@ -78,7 +74,7 @@ export function NoAccessScreen() {
}, [])
const onPressLogout = useCallback(() => {
if (isWeb) {
if (IS_WEB) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
// We can't change the URL via a navigate() call because the navigator
@@ -89,22 +85,35 @@ export function NoAccessScreen() {
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
}, [logoutCurrentAccount])
const birthdateUpdateText = canUpdateBirthday ? (
<Text style={[textStyles]}>
const orgAdmonition = (
<Admonition type="tip">
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
logger.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
birthdateControl.open()
})}>
clicking here
</SimpleInlineLinkText>
.
For organizational accounts, use the birthdate of the person who is
responsible for the account.
</Trans>
</Text>
</Admonition>
)
const birthdateUpdateText = canUpdateBirthday ? (
<>
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
birthdateControl.open()
})}>
clicking here
</SimpleInlineLinkText>
.
</Trans>
</Text>
{orgAdmonition}
</>
) : (
<Text style={[textStyles]}>
<Trans>
@@ -127,7 +136,7 @@ export function NoAccessScreen() {
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb
paddingTop: IS_WEB
? a.p_5xl.padding
: insets.top + a.p_2xl.padding,
paddingBottom: 100,
@@ -165,10 +174,19 @@ export function NoAccessScreen() {
</Trans>
</Text>
{!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}>
<Trans>
Unfortunately, your declared age indicates that you
are not old enough to access Bluesky in your region.
</Trans>
</Text>
)}
{!isBlocked && birthdateUpdateText}
</View>
<AccessSection />
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
<View style={[a.gap_lg]}>
@@ -211,16 +229,7 @@ export function NoAccessScreen() {
</ButtonText>
</Button>
{isUsingAppPassword && (
<Admonition type="info">
<Trans>
Hmm, it looks like you're logged in with an{' '}
<Span style={[a.italic]}>App Password</Span>. To set your
birthdate, you'll need to log in with your main account
password, or ask whomever controls this account to do so.
</Trans>
</Admonition>
)}
{orgAdmonition}
</View>
)}
@@ -260,6 +269,7 @@ export function NoAccessScreen() {
function AccessSection() {
const t = useTheme()
const {_, i18n} = useLingui()
const ax = useAnalytics()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
const locationControl = Dialog.useDialogControl()
@@ -293,7 +303,7 @@ function AccessSection() {
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
ax.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</SimpleInlineLinkText>{' '}
@@ -309,7 +319,7 @@ function AccessSection() {
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
@@ -347,7 +357,7 @@ function AccessSection() {
)}
<View style={[a.gap_xs]}>
{isNative && (
{IS_NATIVE && (
<>
<Admonition>
<Trans>
@@ -15,8 +15,6 @@ import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {isWeb} from '#/platform/detection'
import {isIOS} from '#/platform/detection'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -27,7 +25,8 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_WEB} from '#/env'
export type RedirectOverlayState = {
result: 'success' | 'unknown'
@@ -92,7 +91,7 @@ export function Provider({children}: {children?: React.ReactNode}) {
actorDid: params.get('actorDid') ?? undefined,
})
if (isWeb) {
if (IS_WEB) {
// Clear the URL parameters so they don't re-trigger
history.pushState(null, '', '/')
}
@@ -145,7 +144,7 @@ export function RedirectOverlay() {
// setting a zIndex when using FullWindowOverlay on iOS
// means the taps pass straight through to the underlying content (???)
// so don't set it on iOS. FullWindowOverlay already does the job.
!isIOS && {zIndex: 9999},
!IS_IOS && {zIndex: 9999},
t.atoms.bg,
gtMobile ? a.p_2xl : a.p_xl,
a.align_center,
@@ -174,6 +173,7 @@ export function RedirectOverlay() {
function Inner() {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const agent = useAgent()
const polling = useRef(false)
@@ -187,7 +187,7 @@ function Inner() {
polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {})
ax.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
@@ -218,18 +218,18 @@ function Inner() {
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
ax.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
logger.metric('ageAssurance:redirectDialogFail', {})
ax.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent])
}, [ax, agent])
if (success) {
return (
+15 -2
View File
@@ -6,7 +6,6 @@ import {
AtpAgent,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
@@ -14,6 +13,7 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
hasSnoozedBirthdateUpdateForDid,
@@ -45,7 +45,7 @@ const qc = new QueryClient({
},
})
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
storage: createPersistedQueryStorage('age-assurance'),
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
}
})
}
export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`)
const res = await getConfig()
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
configQueryKey,
res,
)
return res
}
export function useConfigQuery() {
return useQuery(
{
@@ -146,6 +155,10 @@ export function useConfigQuery() {
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
*/
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
/**
* N.B. if prefetch failed above, we'll have no `initialData`, and this
* query will run on startup.
*/
initialData: getConfigFromCache(),
initialDataUpdatedAt: () =>
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
+20 -6
View File
@@ -17,18 +17,32 @@ export const geolocation: Geolocation | undefined = enabled
}
: undefined
export const deviceGeolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
const deviceGeolocationEnabled = false || IS_E2E
export const deviceGeolocation: Geolocation | undefined =
enabled && deviceGeolocationEnabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 16,
rules: [
{
$type: ids.Default,
+23 -5
View File
@@ -14,7 +14,11 @@ import {
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {isUserUnderAdultAge} from '#/ageAssurance/util'
import {
isUnderAge,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
@@ -24,6 +28,7 @@ export {
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
@@ -32,6 +37,8 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
}>({
Access: AgeAssuranceAccess,
@@ -44,6 +51,8 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: false,
chatDisabled: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
},
})
@@ -69,6 +78,7 @@ export function Provider({children}: {children: React.ReactNode}) {
function InnerProvider({children}: {children: React.ReactNode}) {
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
@@ -89,11 +99,17 @@ function InnerProvider({children}: {children: React.ReactNode}) {
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isUnderage = data?.birthdate
? isUserUnderAdultAge(data.birthdate)
const isUnderAdultAge = data?.birthdate
? isUnderAge(data.birthdate, 18)
: true
const isOverRegionMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, config.minAccessAge)
: false
const isOverAppMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isUnderage
state.access !== AgeAssuranceAccess.Full || isUnderAdultAge
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -101,9 +117,11 @@ function InnerProvider({children}: {children: React.ReactNode}) {
flags: {
adultContentDisabled,
chatDisabled,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
},
}
}, [state, data])}>
}, [state, data, config])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+9 -2
View File
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
access: AgeAssuranceAccess.Safe,
}
// should never happen, but need to guard
/**
* This can happen if the prefetch fails (such as due to network issues).
* The query handler will try it again, but if it continues to fail, of
* course we won't have config.
*
* In this case, fail open to avoid blocking users.
*/
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Unknown,
access: AgeAssuranceAccess.Safe,
error: 'config',
}
}
+1
View File
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
}
export function parseStatusFromString(raw: string) {
+7 -9
View File
@@ -12,6 +12,7 @@ import {isNetworkError} from '#/lib/hooks/useCleanError'
import {useAgent} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger'
import {useAnalytics} from '#/analytics'
import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation'
@@ -19,6 +20,7 @@ const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
export function useBeginAgeAssurance() {
const ax = useAnalytics()
const agent = useAgent()
const geolocation = useGeolocation()
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
@@ -48,15 +50,11 @@ export function useBeginAgeAssurance() {
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
logger.metric(
'ageAssurance:api:begin',
{
platform: Platform.OS,
countryCode,
regionCode,
},
{statsig: false},
)
ax.metric('ageAssurance:api:begin', {
platform: Platform.OS,
countryCode,
regionCode,
})
/*
* 2s wait is good actually. Email sending takes a hot sec and this helps
+30 -26
View File
@@ -12,7 +12,23 @@ import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
const DEFAULT_MIN_AGE = 13
export const MIN_ACCESS_AGE = 13
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
/**
* Get age assurance region config based on geolocation, with fallback to
@@ -30,23 +46,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
regionCode: geolocation.regionCode,
})
return (
region || {
countryCode: '*',
regionCode: undefined,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: DEFAULT_MIN_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
)
return region || FALLBACK_REGION_CONFIG
}
/**
@@ -67,6 +67,14 @@ export function useAgeAssuranceRegionConfig() {
}, [config, geolocation])
}
/**
* Hook to get the age assurance region config based on current geolocation.
* Falls back to our app defaults if no region config is found.
*/
export function useAgeAssuranceRegionConfigWithFallback() {
return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
}
/**
* Some users may have erroneously set their birth date to the current date
* if one wasn't set on their account. We previously didn't do validation on
@@ -78,15 +86,11 @@ export function isLegacyBirthdateBug(birthDate: string) {
}
/**
* Returns whether the user is under the minimum age required to use the app.
* This applies to all regions.
* Returns whether the date (converted to an age as a whole integer) is under
* the provided minimum age.
*/
export function isUserUnderMinimumAge(birthDate: string) {
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
}
export function isUserUnderAdultAge(birthDate: string) {
return getAge(new Date(birthDate)) < 18
export function isUnderAge(birthDate: string, age: number) {
return getAge(new Date(birthDate)) < age
}
export function getBirthdateStringFromAge(age: number) {
+3 -1
View File
@@ -5,6 +5,8 @@ import {CARD_ASPECT_RATIO} from '#/lib/constants'
import {native, platform, web} from '#/alf/util/platform'
import * as Layout from '#/components/Layout'
const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)'
export const atoms = {
...baseAtoms,
@@ -103,7 +105,7 @@ export const atoms = {
}),
// special composite animation for dialogs
zoom_fade_in: web({
animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s',
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
}),
/**
+4 -4
View File
@@ -1,6 +1,6 @@
import {type TextStyle} from 'react-native'
import {isAndroid, isWeb} from '#/platform/detection'
import {IS_ANDROID, IS_WEB} from '#/env'
import {type Device, device} from '#/storage'
const WEB_FONT_FAMILIES = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
@@ -39,7 +39,7 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
*/
export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
if (fontFamily === 'theme') {
if (isAndroid) {
if (IS_ANDROID) {
style.fontFamily =
{
400: 'Inter-Regular',
@@ -71,7 +71,7 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
}
}
if (isWeb) {
if (IS_WEB) {
// fallback families only supported on web
style.fontFamily += `, ${WEB_FONT_FAMILIES}`
}
@@ -83,7 +83,7 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
} else {
// fallback families only supported on web
if (isWeb) {
if (IS_WEB) {
style.fontFamily = style.fontFamily || WEB_FONT_FAMILIES
}
+6 -1
View File
@@ -11,7 +11,12 @@ import {
import {themes} from '#/alf/themes'
import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
export {
type TextStyleProp,
type Theme,
utils,
type ViewStyleProp,
} from '@bsky.app/alf'
export {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints'
export * from '#/alf/fonts'
+4 -4
View File
@@ -4,9 +4,9 @@ import {type StyleProp, type TextStyle} from 'react-native'
import {UITextView} from 'react-native-uitextview'
import createEmojiRegex from 'emoji-regex'
import {isNative} from '#/platform/detection'
import {isIOS} from '#/platform/detection'
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
import {IS_NATIVE} from '#/env'
import {IS_IOS} from '#/env'
/**
* Ensures that `lineHeight` defaults to a relative value of `1`, or applies
@@ -34,7 +34,7 @@ export function normalizeTextStyles(
if (s.lineHeight !== 0 && s.lineHeight <= 2) {
s.lineHeight = Math.round(s.fontSize * s.lineHeight)
}
} else if (!isNative) {
} else if (!IS_NATIVE) {
s.lineHeight = s.fontSize
}
@@ -81,7 +81,7 @@ export function renderChildrenWithEmoji(
props: Omit<TextProps, 'children'> = {},
emoji: boolean,
) {
if (!isIOS || !emoji) {
if (!IS_IOS || !emoji) {
return children
}
return Children.map(children, child => {
+2 -2
View File
@@ -2,10 +2,10 @@ import * as SystemUI from 'expo-system-ui'
import {type Theme} from '@bsky.app/alf'
import {logger} from '#/logger'
import {isAndroid} from '#/platform/detection'
import {IS_ANDROID} from '#/env'
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
if (isAndroid) {
if (IS_ANDROID) {
try {
if (themeType === 'theme') {
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
+3 -2
View File
@@ -2,9 +2,9 @@ import React from 'react'
import {type ColorSchemeName, useColorScheme} from 'react-native'
import {type ThemeName} from '@bsky.app/alf'
import {isWeb} from '#/platform/detection'
import {useThemePrefs} from '#/state/shell'
import {dark, dim, light} from '#/alf/themes'
import {IS_WEB} from '#/env'
export function useColorModeTheme(): ThemeName {
const theme = useThemeName()
@@ -40,7 +40,7 @@ function getThemeName(
function updateDocument(theme: ThemeName) {
// @ts-ignore web only
if (isWeb && typeof window !== 'undefined') {
if (IS_WEB && typeof window !== 'undefined') {
// @ts-ignore web only
const html = window.document.documentElement
// @ts-ignore web only
@@ -51,6 +51,7 @@ function updateDocument(theme: ThemeName) {
html.classList.add(`theme--${theme}`)
// set color to 'theme-color' meta tag
meta?.setAttribute('content', getBackgroundColor(theme))
window.localStorage.setItem('ALF_THEME', theme)
}
}
+32
View File
@@ -0,0 +1,32 @@
import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
/**
* Tracks passive analytics like app foreground/background time.
*/
export function PassiveAnalytics() {
const ax = useAnalytics()
const lastActive = useRef(
getCurrentState() === 'active' ? performance.now() : null,
)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
lastActive.current = performance.now()
ax.metric('state:foreground', {})
} else if (lastActive.current !== null) {
ax.metric('state:background', {
secondsActive: Math.round(
(performance.now() - lastActive.current) / 1e3,
),
})
}
})
return () => sub.remove()
}, [ax])
return null
}
+89
View File
@@ -0,0 +1,89 @@
import {MMKV} from '@bsky.app/react-native-mmkv'
import {setPolyfills} from '@growthbook/growthbook'
import {GrowthBook} from '@growthbook/growthbook-react'
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
import * as env from '#/env'
export {Features} from '#/analytics/features/types'
const CACHE = new MMKV({id: 'bsky_features_cache'})
setPolyfills({
localStorage: {
getItem: key => {
const value = CACHE.getString(key)
return value != null ? JSON.parse(value) : null
},
setItem: async (key, value) => {
CACHE.set(key, value)
},
},
})
/**
* We vary the amount of time we wait for GrowthBook to fetch feature
* gates based on the strategy specified.
*/
export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500
export const features = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
})
/**
* Initializer promise that must be awaited before using the GrowthBook
* instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be
* fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In
* that case, we may see a flash of uncustomized content until the
* initialization completes.
*/
export const init = new Promise<void>(async y => {
await features.init({timeout: TIMEOUT_INIT})
y()
})
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
*/
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
await features.refreshFeatures({
timeout:
strategy === 'prefer-low-latency'
? TIMEOUT_PREFER_LOW_LATENCY
: TIMEOUT_PREFER_FRESH_GATES,
})
}
/**
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
* attributes are manually configured in the GrowthBook dashboard. So these
* values need to match exactly. Therefore, let's add them here manually to and
* not spread them to avoid mistakes.
*/
export function setAttributes({
base,
geolocation,
session,
preferences,
}: Metadata) {
features.setAttributes({
deviceId: base.deviceId,
sessionId: base.sessionId,
platform: base.platform,
appVersion: base.appVersion,
countryCode: geolocation.countryCode,
regionCode: geolocation.regionCode,
did: session?.did,
isBskyPds: session?.isBskyPds,
appLanguage: preferences?.appLanguage,
contentLanguages: preferences?.contentLanguages,
currentScreen: getNavigationMetadata()?.currentScreen,
})
}
+12
View File
@@ -0,0 +1,12 @@
export enum Features {
// core flags
IsBskyTeam = 'is_bsky_team',
// debug flags
DebugFeedContext = 'debug_feed_context',
// feature flags
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
}
+26
View File
@@ -0,0 +1,26 @@
import uuid from 'react-native-uuid'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {device} from '#/storage'
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
export async function getAndMigrateDeviceId() {
const migrated = getDeviceId()
if (migrated) return migrated
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
device.set(['deviceId'], id)
return id
}
export function getDeviceId() {
return device.get(['deviceId'])
}
export function getDeviceIdOrThrow() {
const id = device.get(['deviceId'])
if (!id) {
throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`)
}
return id
}
+2
View File
@@ -0,0 +1,2 @@
export * from '#/analytics/identifiers/device'
export * from '#/analytics/identifiers/session'
+79
View File
@@ -0,0 +1,79 @@
jest.mock('#/storage', () => ({
device: {
get: jest.fn(),
set: jest.fn(),
},
}))
jest.mock('#/analytics/identifiers/util', () => ({
isSessionIdExpired: jest.fn(),
}))
jest.mock('#/lib/appState', () => ({
onAppStateChange: jest.fn(() => ({remove: jest.fn()})),
}))
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
function getMocks() {
const {device} = require('#/storage')
const {isSessionIdExpired} = require('#/analytics/identifiers/util')
return {
device: jest.mocked(device),
isSessionIdExpired: jest.mocked(isSessionIdExpired),
}
}
describe('session initialization', () => {
it('creates new session and sets timestamp when none exists', () => {
const {device, isSessionIdExpired} = getMocks()
device.get.mockReturnValue(undefined)
isSessionIdExpired.mockReturnValue(false)
const {getInitialSessionId} = require('./session')
const id = getInitialSessionId()
expect(id).toBeDefined()
expect(typeof id).toBe('string')
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
expect(device.set).toHaveBeenCalledWith(
['nativeSessionIdLastEventAt'],
expect.any(Number),
)
})
it('reuses existing session when not expired', () => {
const {device, isSessionIdExpired} = getMocks()
const existingId = 'existing-session-id'
device.get.mockImplementation((key: string[]) => {
if (key[0] === 'nativeSessionId') return existingId
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now()
return undefined
})
isSessionIdExpired.mockReturnValue(false)
const {getInitialSessionId} = require('./session')
expect(getInitialSessionId()).toBe(existingId)
})
it('creates new session when existing is expired', () => {
const {device, isSessionIdExpired} = getMocks()
const existingId = 'existing-session-id'
device.get.mockImplementation((key: string[]) => {
if (key[0] === 'nativeSessionId') return existingId
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999
return undefined
})
isSessionIdExpired.mockReturnValue(true)
const {getInitialSessionId} = require('./session')
const id = getInitialSessionId()
expect(id).not.toBe(existingId)
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
})
})
+40
View File
@@ -0,0 +1,40 @@
import {useEffect, useState} from 'react'
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
import {isSessionIdExpired} from '#/analytics/identifiers/util'
import {device} from '#/storage'
let sessionId = (() => {
const existing = device.get(['nativeSessionId'])
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
device.set(['nativeSessionId'], id)
device.set(['nativeSessionIdLastEventAt'], Date.now())
return id
})()
export function getInitialSessionId() {
return sessionId
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
if (isSessionIdExpired(lastEvent)) {
sessionId = uuid.v4()
device.set(['nativeSessionId'], sessionId)
setId(sessionId)
}
}
device.set(['nativeSessionIdLastEventAt'], Date.now())
})
return () => sub.remove()
}, [])
return id
}
+44
View File
@@ -0,0 +1,44 @@
import {useEffect, useState} from 'react'
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
import {isSessionIdExpired} from '#/analytics/identifiers/util'
const SESSION_ID_KEY = 'bsky_session_id'
const LAST_EVENT_KEY = 'bsky_session_id_last_event_at'
let sessionId = (() => {
const existing = window.sessionStorage.getItem(SESSION_ID_KEY)
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, id)
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
return id
})()
export function getInitialSessionId() {
return sessionId
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
if (isSessionIdExpired(lastEvent)) {
sessionId = uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
setId(sessionId)
}
}
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
})
return () => sub.remove()
}, [])
return id
}
+9
View File
@@ -0,0 +1,9 @@
import * as env from '#/env'
const ONE_MIN = 60 * 1e3
const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native
export function isSessionIdExpired(since: number | undefined) {
if (since === undefined) return false
return Date.now() - since >= TTL
}
+232
View File
@@ -0,0 +1,232 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {Platform} from 'react-native'
import {Logger} from '#/logger'
import {
Features,
features as feats,
init,
refresh,
setAttributes,
} from '#/analytics/features'
import {
getAndMigrateDeviceId,
getDeviceId,
getInitialSessionId,
useSessionId,
} from '#/analytics/identifiers'
import {
getMetadataForLogger,
getNavigationMetadata,
type MergeableMetadata,
type Metadata,
} from '#/analytics/metadata'
import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env'
import {useGeolocation} from '#/geolocation'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
export const features = {init, refresh}
export {Features} from '#/analytics/features'
export {type Metrics} from '#/analytics/metrics'
type LoggerType = {
debug: Logger['debug']
info: Logger['info']
log: Logger['log']
warn: Logger['warn']
error: Logger['error']
/**
* Clones the existing logger and overrides the `context` value. Existing
* metadata is inherited.
*
* ```ts
* const ax = useAnalytics()
* const logger = ax.logger.useChild(ax.logger.Context.Notifications)
* ```
*/
useChild: (context: Exclude<Logger['context'], undefined>) => LoggerType
Context: typeof Logger.Context
}
export type AnalyticsContextType = {
metadata: Metadata
logger: LoggerType
metric: <E extends keyof Metrics>(
event: E,
payload: Metrics[E],
metadata?: MergeableMetadata,
) => void
features: typeof Features & {
enabled(feature: Features): boolean
}
}
export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'>
function createLogger(
context: Logger['context'],
metadata: Partial<Metadata>,
): LoggerType {
const logger = Logger.create(context, metadata)
return {
debug: logger.debug.bind(logger),
info: logger.info.bind(logger),
log: logger.log.bind(logger),
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
useChild: (context: Exclude<Logger['context'], undefined>) => {
return useMemo(() => createLogger(context, metadata), [context, metadata])
},
Context: Logger.Context,
}
}
const Context = createContext<AnalyticsBaseContextType>({
logger: createLogger(Logger.Context.Default, {}),
metric: (event, payload, metadata) => {
if (metadata && '__meta' in metadata) {
delete metadata.__meta
}
metrics.track(event, payload, {
...metadata,
navigation: getNavigationMetadata(),
})
},
metadata: {
base: {
deviceId: getDeviceId() ?? 'unknown',
sessionId: getInitialSessionId(),
platform: Platform.OS,
appVersion: env.APP_VERSION,
bundleIdentifier: env.BUNDLE_IDENTIFIER,
bundleDate: env.BUNDLE_DATE,
referrerSrc: refParams.src,
referrerUrl: refParams.url,
},
geolocation: device.get(['mergedGeolocation']) || {
countryCode: '',
regionCode: '',
},
},
})
/**
* Ensures that deviceId is set and migrated from legacy storage. Handled on
* startup in `App.<platform>.tsx`. This must be awaited prior to the app
* booting up.
*/
export const setupDeviceId = getAndMigrateDeviceId()
/**
* Analytics context provider. Decorates the parent analytics context with
* additional metadata. Nesting should be done carefully and sparingly.
*/
export function AnalyticsContext({
children,
metadata,
}: {
children: React.ReactNode
metadata?: MergeableMetadata
}) {
if (metadata) {
if (!('__meta' in metadata)) {
throw new Error(
'Use the useMeta() helper when passing metadata to AnalyticsContext',
)
}
}
const sessionId = useSessionId()
const geolocation = useGeolocation()
const parentContext = useContext(Context)
const childContext = useMemo(() => {
const combinedMetadata = {
...parentContext.metadata,
...metadata,
base: {
...parentContext.metadata.base,
sessionId,
},
geolocation,
}
const context: AnalyticsBaseContextType = {
...parentContext,
logger: createLogger(
Logger.Context.Default,
getMetadataForLogger(combinedMetadata),
),
metadata: combinedMetadata,
metric: (event, payload, extraMetadata) => {
parentContext.metric(event, payload, {
...combinedMetadata,
...extraMetadata,
})
},
}
return context
}, [sessionId, geolocation, parentContext, metadata])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
/**
* Feature gates provider. Decorates the parent analytics context with
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
* and below the `<Fragment key={did} />` breaker in `App.<platform>.tsx`.
*/
export function AnalyticsFeaturesContext({
children,
}: {
children: React.ReactNode
}) {
const parentContext = useContext(Context)
/**
* Side-effect: we need to synchronously set this during the
* same render cycle. It does not trigger a re-render, it just
* sets properties on the singleton GrowthBook instance.
*/
setAttributes(parentContext.metadata)
useEffect(() => {
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
})
}, [parentContext.metric])
const childContext = useMemo<AnalyticsContextType>(() => {
return {
...parentContext,
features: {
enabled: feats.isOn.bind(feats),
...Features,
},
}
}, [parentContext])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
/**
* Basic analytics context without feature gates. Should really only be used
* above the `AnalyticsFeaturesContext` provider.
*/
export function useAnalyticsBase() {
return useContext(Context)
}
/**
* The main analytics context, including feature gates. Use this everywhere you
* need metrics, features, or logging within the React tree.
*/
export function useAnalytics() {
const ctx = useContext(Context)
if (!('features' in ctx)) {
throw new Error(
'useAnalytics must be used within an AnalyticsFeaturesContext',
)
}
return ctx as AnalyticsContextType
}
+79
View File
@@ -0,0 +1,79 @@
import {type Geolocation} from '#/geolocation'
export type BaseMetadata = {
deviceId: string
sessionId: string
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
referrerSrc: string
referrerUrl: string
}
export type GeolocationMetadata = Geolocation
export type SessionMetadata = {
did: string
isBskyPds: boolean
}
export type PreferencesMetadata = {
appLanguage: string
contentLanguages: string[]
}
export type MergeableMetadata = {
session?: SessionMetadata
preferences?: PreferencesMetadata
/**
* Navigation metadata is not actually available on this object, instead it's
* merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details.
*/
navigation?: NavigationMetadata
}
export type Metadata = {
base: BaseMetadata
geolocation: GeolocationMetadata
} & MergeableMetadata
/*
* Navigation metadata is handle out-of-band from React, since we don't want to
* slow down screen transitions in any way, and there doesn't seem to be a nice
* way to get current navigation state without an additional re-render between
* navigations.
*
* So instead of this data being available on the Metadata object, it's stored
* here and merged in at time-of-log/metric.
*/
export type NavigationMetadata = {
previousScreen?: string
currentScreen?: string
}
let navigationMetadata: NavigationMetadata | undefined
export function getNavigationMetadata() {
return navigationMetadata
}
export function setNavigationMetadata(meta: NavigationMetadata | undefined) {
navigationMetadata = meta
}
/**
* We don't want or need to send all data to the logger
*/
export function getMetadataForLogger({
base,
geolocation,
session,
}: Metadata): Record<string, any> {
return {
deviceId: base.deviceId,
sessionId: base.sessionId,
platform: base.platform,
appVersion: base.appVersion,
countryCode: geolocation.countryCode,
regionCode: geolocation.regionCode,
isBskyPds: session?.isBskyPds || 'anonymous',
}
}
+176
View File
@@ -0,0 +1,176 @@
import {MetricsClient} from './client'
let appStateCallback: (state: string) => void
jest.mock('#/lib/appState', () => ({
onAppStateChange: jest.fn(cb => {
appStateCallback = cb
return {remove: jest.fn()}
}),
}))
jest.mock('#/logger', () => ({
Logger: {
create: () => ({
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
}),
Context: {Metric: 'metric'},
},
}))
jest.mock('#/env', () => ({
METRICS_API_HOST: 'https://test.metrics.api',
IS_WEB: false,
}))
type TestEvents = {
click: {button: string}
view: {screen: string}
}
describe('MetricsClient', () => {
let fetchMock: jest.Mock
let fetchRequests: {body: any}[]
beforeEach(() => {
jest.useFakeTimers({advanceTimers: true})
fetchRequests = []
fetchMock = jest.fn().mockImplementation(async (_url, options) => {
const body = JSON.parse(options.body)
fetchRequests.push({body})
return {ok: true, status: 200}
})
global.fetch = fetchMock
})
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})
it('flushes events on interval', async () => {
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
client.track('view', {screen: 'home'})
expect(fetchRequests).toHaveLength(0)
// Advance past the 10 second interval
await jest.advanceTimersByTimeAsync(10_000)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(2)
expect(fetchRequests[0].body.events[0].event).toBe('click')
expect(fetchRequests[0].body.events[1].event).toBe('view')
})
it('flushes when maxBatchSize is exceeded', async () => {
const client = new MetricsClient<TestEvents>()
client.maxBatchSize = 5
// Add events up to maxBatchSize (should not flush yet)
for (let i = 0; i < 5; i++) {
client.track('click', {button: `btn-${i}`})
}
expect(fetchRequests).toHaveLength(0)
// One more event should trigger flush (> maxBatchSize)
client.track('click', {button: 'btn-trigger'})
// Allow microtasks to run
await jest.advanceTimersByTimeAsync(0)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(6)
})
it('retries failed events once on 500 response', async () => {
let requestCount = 0
fetchMock.mockImplementation(async (_url, options) => {
requestCount++
const body = JSON.parse(options.body)
if (requestCount === 1) {
// First request fails with 500 - "Failed to fetch" triggers isNetworkError
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
}
// Retry succeeds
fetchRequests.push({body})
return {ok: true, status: 200}
})
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
// Trigger flush via interval
await jest.advanceTimersByTimeAsync(10_000)
expect(requestCount).toBe(1)
expect(fetchRequests).toHaveLength(0)
// Simulate app coming to foreground to trigger retry
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(1)
expect(fetchRequests[0].body.events[0].event).toBe('click')
})
it('does not retry more than once', async () => {
let requestCount = 0
fetchMock.mockImplementation(async () => {
requestCount++
// Always fail with network-like error
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
})
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
// First flush fails
await jest.advanceTimersByTimeAsync(10_000)
expect(requestCount).toBe(1)
// Retry also fails
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2)
// Another foreground event should not retry again (events are dropped)
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2) // No additional requests
})
it('flushes when app goes to background', async () => {
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
expect(fetchRequests).toHaveLength(0)
// Simulate app going to background
appStateCallback('background')
await jest.advanceTimersByTimeAsync(0)
expect(fetchRequests).toHaveLength(1)
})
})
+116
View File
@@ -0,0 +1,116 @@
import {onAppStateChange} from '#/lib/appState'
import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import * as env from '#/env'
type Event<M extends Record<string, any>> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Record<string, any>
}
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t'
const logger = Logger.create(Logger.Context.Metric, {})
export class MetricsClient<M extends Record<string, any>> {
maxBatchSize = 100
private started: boolean = false
private queue: Event<M>[] = []
private failedQueue: Event<M>[] = []
private flushInterval: NodeJS.Timeout | null = null
start() {
if (this.started) return
this.started = true
this.flushInterval = setInterval(() => {
this.flush()
}, 10_000)
onAppStateChange(state => {
if (state === 'active') {
this.retryFailedLogs()
} else {
this.flush()
}
})
}
track<E extends keyof M>(
event: E,
payload: M[E],
metadata: Record<string, any> = {},
) {
this.start()
const e = {
time: Date.now(),
event,
payload,
metadata,
}
this.queue.push(e)
logger.debug(`event: ${e.event as string}`, e)
if (this.queue.length > this.maxBatchSize) {
this.flush()
}
}
flush() {
if (!this.queue.length) return
const events = this.queue.splice(0, this.queue.length)
this.sendBatch(events)
}
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
logger.debug(`sendBatch: ${events.length}`, {
isRetry,
})
try {
const body = JSON.stringify({events})
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
const success = navigator.sendBeacon(
TRACKING_ENDPOINT,
new Blob([body], {type: 'application/json'}),
)
if (!success) {
// construct a "network error" for `isNetworkError` to work
throw new Error(`Failed to fetch: sendBeacon returned false`)
}
} else {
const res = await fetch(TRACKING_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({events}),
keepalive: true,
})
if (!res.ok) {
const error = await res.text().catch(() => 'Unknown error')
// construct a "network error" for `isNetworkError` to work
throw new Error(`${res.status} Failed to fetch — ${error}`)
}
}
} catch (e: any) {
if (isNetworkError(e)) {
if (isRetry) return // retry once
this.failedQueue.push(...events)
return
}
logger.error(`Failed to send metrics`, {
safeMessage: e.toString(),
})
}
}
private retryFailedLogs() {
if (!this.failedQueue.length) return
const events = this.failedQueue.splice(0, this.failedQueue.length)
this.sendBatch(events, true)
}
}
+6
View File
@@ -0,0 +1,6 @@
import {MetricsClient} from '#/analytics/metrics/client'
import {type Events} from '#/analytics/metrics/types'
export type {Events as Metrics} from '#/analytics/metrics/types'
export * from '#/analytics/metrics/utils'
export const metrics = new MetricsClient<Events>()
@@ -1,11 +1,21 @@
/*
* Do not import runtime code into this file
*/
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = {
export type Events = {
// App events
init: {
initMs: number
}
'experiment:viewed': {
experimentId: string
variationId: string
}
'account:loggedIn': {
logContext:
| 'LoginForm'
@@ -138,6 +148,7 @@ export type MetricEvents = {
feedUrl: string
feedType: string
index: number
reason?: string
}
'feed:endReached': {
feedUrl: string
@@ -176,13 +187,19 @@ export type MetricEvents = {
'feed:suggestion:press': {
feedUrl: string
}
'feed:showMore': {
feed: string
feedContext: string
'post:showMore': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'feed:showLess': {
feed: string
feedContext: string
'post:showLess': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'feed:clickthrough': {
feed: string
@@ -216,6 +233,53 @@ export type MetricEvents = {
persist: boolean
hasChanged: boolean
}
'composer:open': {
logContext:
| 'Fab'
| 'PostReply'
| 'QuotePost'
| 'ProfileFeed'
| 'Deeplink'
| 'Other'
isReply: boolean
hasQuote: boolean
hasDraft: boolean
}
'draft:save': {
isNewDraft: boolean
hasText: boolean
hasImages: boolean
hasVideo: boolean
hasGif: boolean
hasQuote: boolean
hasLink: boolean
postCount: number
textLength: number
}
'draft:load': {
draftAgeMs: number
hasText: boolean
hasImages: boolean
hasVideo: boolean
hasGif: boolean
postCount: number
}
'draft:delete': {
logContext: 'DraftsList'
draftAgeMs: number
}
'draft:listOpen': {
draftCount: number
}
'draft:post': {
draftAgeMs: number
wasEdited: boolean
}
'draft:discard': {
logContext: 'ComposerClose' | 'BeforeDraftsList'
hadContent: boolean
textLength: number
}
// Data events
'account:create:begin': {}
@@ -238,6 +302,8 @@ export type MetricEvents = {
isReply: boolean
}
'post:like': {
uri: string
authorDid: string
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
@@ -246,26 +312,87 @@ export type MetricEvents = {
feedDescriptor?: string
}
'post:repost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'post:unlike': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'post:unrepost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'post:mute': {}
'post:unmute': {}
'post:mute': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:unmute': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:pin': {}
'post:unpin': {}
'post:bookmark': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:unbookmark': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickReply': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickQuotePost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughAuthor': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughItem': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:clickthroughEmbed': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
position?: number
}
'post:view': {
uri: string
@@ -304,7 +431,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
@@ -341,8 +467,8 @@ export type MetricEvents = {
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile'
recId?: number
location: 'Card' | 'Profile' | 'FollowAll'
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -353,7 +479,7 @@ export type MetricEvents = {
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Onboarding'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -366,7 +492,7 @@ export type MetricEvents = {
| 'Profile'
| 'Onboarding'
| 'ProgressGuide'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -379,6 +505,12 @@ export type MetricEvents = {
| 'Profile'
| 'Onboarding'
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number | string
position: number
suggestedDid: string
}
'profile:unfollow': {
logContext:
| 'RecommendedFollowsItem'
@@ -392,7 +524,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
@@ -559,7 +690,14 @@ export type MetricEvents = {
'live:view:profile': {subject: string}
'live:view:post': {subject: string; feed?: string}
'share:open': {context: 'feed' | 'thread'}
'post:share': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
postContext: 'feed' | 'thread'
position?: number
}
'share:press:copyLink': {}
'share:press:nativeShare': {}
'share:press:openDmSearch': {}
@@ -714,4 +852,27 @@ export type MetricEvents = {
}
// user pressed the remove all data button
'contacts:settings:removeData': {}
'liveEvents:feedBanner:seen': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:click': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:hide': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:feedBanner:unhide': {
feed: string
context: LiveEventFeedMetricContext
}
'liveEvents:hideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
}
+7
View File
@@ -0,0 +1,7 @@
export function toClout(n: number | null | undefined): number | undefined {
if (n == null) {
return undefined
} else {
return Math.max(0, Math.round(Math.log(n)))
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* This is used for our own Bluesky post embeds, and maybe other things.
*
* In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used.
*/
import * as env from '#/env'
let refSrc = ''
let refUrl = ''
if (env.IS_WEB) {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export const src = refSrc
export const url = refUrl
+33
View File
@@ -0,0 +1,33 @@
import {useMemo} from 'react'
import {BSKY_SERVICE} from '#/lib/constants'
import {type SessionAccount} from '#/state/session'
import {
type MergeableMetadata,
type SessionMetadata,
} from '#/analytics/metadata'
/**
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
* type guard.
*/
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
// @ts-ignore
m.__meta = true
return m
}
export function accountToSessionMetadata(
account: SessionAccount | undefined,
): SessionMetadata | undefined {
if (!account) {
return
} else {
return {
did: account.did,
isBskyPds: account.service.startsWith(BSKY_SERVICE),
}
}
}
+5 -5
View File
@@ -49,7 +49,6 @@ import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {
Context,
@@ -71,6 +70,7 @@ import {
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {createPortalGroup} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS} from '#/env'
import {Backdrop} from './Backdrop'
export {
@@ -81,14 +81,14 @@ export {
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
const SPRING_IN: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
mass: IS_IOS ? 1.25 : 0.75,
damping: 50,
stiffness: 1100,
restDisplacementThreshold: 0.01,
}
const SPRING_OUT: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
mass: IS_IOS ? 1.25 : 0.75,
damping: 150,
stiffness: 1000,
restDisplacementThreshold: 0.01,
@@ -209,7 +209,7 @@ export function Root({children}: {children: React.ReactNode}) {
)
useEffect(() => {
if (isAndroid && context.isOpen) {
if (IS_ANDROID && context.isOpen) {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
context.close()
return true
@@ -331,7 +331,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
<GestureDetector gesture={composedGestures}>
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
{children({
isNative: true,
IS_NATIVE: true,
control: {isOpen: context.isOpen, open},
state: {
pressed: false,
+2 -2
View File
@@ -85,7 +85,7 @@ export type TriggerProps = {
}
export type TriggerChildProps =
| {
isNative: true
IS_NATIVE: true
control: {
isOpen: boolean
open: (mode: 'full' | 'auxiliary-only') => void
@@ -115,7 +115,7 @@ export type TriggerChildProps =
}
}
| {
isNative: false
IS_NATIVE: false
control: Dialog.DialogOuterProps['control']
state: {
hovered: false
+1 -1
View File
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
export const Context = createContext<DialogContextProps>({
close: () => {},
isNativeDialog: false,
IS_NATIVEDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
+23 -13
View File
@@ -26,7 +26,6 @@ import {useLingui} from '@lingui/react'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
@@ -39,6 +38,7 @@ import {
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {IS_ANDROID, IS_IOS} from '#/env'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
type BottomSheetSnapPointChangeEvent,
@@ -154,7 +154,7 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: true,
IS_NATIVEDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
@@ -209,7 +209,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
useEnableKeyboardController(isIOS)
useEnableKeyboardController(IS_IOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
@@ -224,7 +224,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
)
let paddingBottom = 0
if (isIOS) {
if (IS_IOS) {
paddingBottom += keyboardHeight / 4
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.bottom + tokens.space.md
@@ -240,7 +240,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isAndroid) {
if (!IS_ANDROID) {
return
}
const {contentOffset} = e.nativeEvent
@@ -260,12 +260,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={isAndroid ? false : undefined}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
scrollEventThrottle={50}
onScroll={isAndroid ? onScroll : undefined}
onScroll={IS_ANDROID ? onScroll : undefined}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
@@ -289,11 +289,11 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(isIOS)
useEnableKeyboardController(IS_IOS)
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!isAndroid) {
if (!IS_ANDROID) {
return
}
const {contentOffset} = e
@@ -311,7 +311,7 @@ export const InnerFlatList = React.forwardRef<
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
ref={ref}
showsVerticalScrollIndicator={isAndroid ? false : undefined}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
style={[a.h_full, style]}
/>
@@ -326,7 +326,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
const {height} = useReanimatedKeyboardAnimation()
const animatedStyle = useAnimatedStyle(() => {
if (!isIOS) return {}
if (!IS_IOS) return {}
return {
transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
}
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
)
}
export function Handle({difference = false}: {difference?: boolean}) {
export function Handle({
difference = false,
fill,
}: {
difference?: boolean
fill?: string
}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
opacity: 0.75,
}
: {
backgroundColor: t.palette.contrast_975,
backgroundColor: fill || t.palette.contrast_975,
opacity: 0.5,
},
]}
@@ -403,3 +409,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
export function Close() {
return null
}
export function Backdrop() {
return null
}
+5 -5
View File
@@ -3,8 +3,8 @@ import {
FlatList,
type FlatListProps,
type GestureResponderEvent,
Pressable,
type StyleProp,
TouchableWithoutFeedback,
View,
type ViewStyle,
} from 'react-native'
@@ -98,7 +98,7 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
IS_NATIVEDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
@@ -113,7 +113,7 @@ export function Outer({
<Portal>
<Context.Provider value={context}>
<RemoveScrollBar />
<TouchableWithoutFeedback
<Pressable
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)}
onPress={handleBackgroundPress}>
@@ -146,7 +146,7 @@ export function Outer({
{children}
</View>
</View>
</TouchableWithoutFeedback>
</Pressable>
</Context.Provider>
</Portal>
)}
@@ -304,7 +304,7 @@ export function Handle() {
return null
}
function Backdrop() {
export function Backdrop() {
const t = useTheme()
const {reduceMotionEnabled} = useA11y()
return (
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback} from 'react'
import {SystemBars} from 'react-native-edge-to-edge'
import {isIOS} from '#/platform/detection'
import {IS_IOS} from '#/env'
/**
* If we're calling a system API like the image picker that opens a sheet
@@ -9,7 +9,7 @@ import {isIOS} from '#/platform/detection'
*/
export function useSheetWrapper() {
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
if (isIOS) {
if (IS_IOS) {
const entry = SystemBars.pushStackEntry({
style: {
statusBar: 'light',
+1 -1
View File
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
isNativeDialog: boolean
IS_NATIVEDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
+35 -3
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useMemo} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
type AppBskyFeedDefs,
@@ -21,19 +21,21 @@ import {
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, select, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
@@ -49,7 +51,11 @@ export function Default(props: Props) {
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline title={view.displayName} creator={view.creator} />
<TitleAndByline
title={view.displayName}
creator={view.creator}
uri={view.uri}
/>
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
uri,
}: {
title: string
creator?: bsky.profile.AnyProfileView
uri?: string
}) {
const t = useTheme()
const activeLiveEvents = useActiveLiveEventFeedUris()
const liveColor = useMemo(
() =>
select(t.name, {
dark: t.palette.negative_600,
dim: t.palette.negative_600,
light: t.palette.negative_500,
}),
[t],
)
return (
<View style={[a.flex_1]}>
{uri && activeLiveEvents.has(uri) && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<LiveIcon size="xs" fill={liveColor} />
<Text
style={[
a.text_2xs,
a.font_medium,
a.leading_snug,
{color: liveColor},
]}>
<Trans>Happening now</Trans>
</Text>
</View>
)}
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+356 -147
View File
@@ -1,26 +1,33 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {
Easing,
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {isIOS} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {
useSuggestedFollowsByActorQuery,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
native,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -31,13 +38,18 @@ import {useDialogControl} from '#/components/Dialog'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const DISMISS_ANIMATION_DURATION = 200
const MOBILE_CARD_WIDTH = 165
const FINAL_CARD_WIDTH = 120
@@ -49,6 +61,7 @@ function CardOuter({
const {gtMobile} = useBreakpoints()
return (
<View
testID="CardOuter"
style={[
a.flex_1,
a.w_full,
@@ -146,7 +159,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
@@ -202,6 +215,9 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
data,
@@ -209,29 +225,168 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
} = useSuggestedFollowsByActorQuery({
did,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
recId={data?.recId}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={error}
viewContext="profile"
onDismiss={onDismiss}
/>
)
}
export function SuggestedFollowsHome() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
profiles,
error,
profiles: experimentalProfiles,
error: experimentalError,
} = useExperimentalSuggestedUsersQuery()
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: undefined})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={experimentalError || suggestionsError}
viewContext="feed"
onDismiss={onDismiss}
/>
)
}
@@ -240,18 +395,21 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
recId,
totalProfileCount,
viewContext = 'feed',
onDismiss,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[]
recId?: number
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
totalProfileCount?: number
error: Error | null
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
@@ -268,12 +426,11 @@ export function ProfileGrid({
const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
// Callback to fire seen events
const fireSeen = useCallback(() => {
@@ -283,22 +440,18 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
logger.metric(
'suggestedUser:seen',
{
logContext,
recId,
position: index,
suggestedDid: profile.did,
category: null,
},
{statsig: true},
)
if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', {
logContext,
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
category: null,
})
}
})
}, [isLoading, error, profiles, maxLength, logContext, recId])
}, [ax, isLoading, error, profiles, maxLength, logContext])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -363,20 +516,16 @@ export function ProfileGrid({
: error || !profiles.length
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
<Animated.View
key={profile.actor.did}
layout={native(
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
Easing.out(Easing.exp),
),
)}
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
// for web, as the cards are static, not in a list
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
style={[
a.flex_1,
gtMobile &&
@@ -386,68 +535,123 @@ export function ProfileGrid({
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
]}>
{({hovered, pressed}) => (
<CardOuter
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
<ProfileCard.Outer>
<View
style={[
a.flex_col,
a.align_center,
a.gap_sm,
a.pb_sm,
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
numberOfLines={2}
<ProfileCard.Link
profile={profile.actor}
onPress={() => {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
category: null,
})
}}
style={[a.flex_1]}>
{({hovered, pressed}) => (
<CardOuter
style={[
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
{onDismiss && (
<Button
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.actor.did,
recId: profile.recId,
})
}}
style={[
t.atoms.text_contrast_medium,
a.text_center,
a.text_xs,
]}
a.absolute,
a.z_10,
a.p_xs,
{top: -4, right: -4},
]}>
{({
hovered: dismissHovered,
pressed: dismissPressed,
}) => (
<X
size="xs"
fill={
dismissHovered || dismissPressed
? t.atoms.text.color
: t.atoms.text_contrast_medium.color
}
/>
)}
</Button>
)}
<View
style={[
a.flex_col,
a.align_center,
a.gap_sm,
a.pb_sm,
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile.actor}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile.actor}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile.actor}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
a.text_center,
a.text_xs,
]}
/>
</View>
</View>
</View>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
/>
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
<ProfileCard.FollowButton
profile={profile.actor}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
ax.metric('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
category: null,
})
}}
/>
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
</Animated.View>
))
if (error || (!isLoading && profiles.length < minLength)) {
logger.debug(`Not enough profiles to show suggested follows`)
// Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
ax.logger.debug(`Not enough profiles to show suggested follows`)
return null
}
@@ -459,7 +663,7 @@ export function ProfileGrid({
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
<View
style={[
a.px_lg,
@@ -468,20 +672,16 @@ export function ProfileGrid({
a.align_center,
a.justify_between,
]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
{isFeedContext ? (
<Trans>Suggested for you</Trans>
) : (
<Trans>Similar accounts</Trans>
)}
<Trans>Suggested for you</Trans>
</Text>
{!isProfileHeaderContext && (
<Button
label={_(msg`See more suggested profiles`)}
onPress={() => {
followDialogControl.open()
logEvent('suggestedUser:seeMore', {
ax.metric('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile',
})
}}>
@@ -505,41 +705,42 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} />
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
</View>
</View>
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
logger.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</LayoutAnimationConfig>
</View>
)
}
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
const t = useTheme()
const {_} = useLingui()
return (
@@ -553,7 +754,6 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
a.gap_sm,
a.p_md,
a.rounded_lg,
t.atoms.shadow_sm,
{width: FINAL_CARD_WIDTH},
]}>
<ButtonIcon icon={ArrowRight} size="lg" />
@@ -565,9 +765,10 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
)
}
const numFeedsToDisplay = 3
export function SuggestedFeeds() {
const numFeedsToDisplay = 3
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay,
@@ -575,7 +776,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const feeds = useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
@@ -600,7 +801,7 @@ export function SuggestedFeeds() {
key={feed.uri}
view={feed}
onPress={() => {
logEvent('feed:interstitial:feedCard:press', {})
ax.metric('feed:interstitial:feedCard:press', {})
}}>
{({hovered, pressed}) => (
<CardOuter
@@ -611,6 +812,7 @@ export function SuggestedFeeds() {
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
uri={feed.uri}
/>
</FeedCard.Header>
<FeedCard.Description
@@ -702,8 +904,15 @@ export function SuggestedFeeds() {
export function ProgressGuide() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
<View
style={[
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
!gtMobile && {marginTop: 4},
]}>
<ProgressGuideList />
</View>
)
+3 -3
View File
@@ -9,7 +9,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isWeb} from '#/platform/detection'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
@@ -19,6 +18,7 @@ import {
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
} from '#/components/icons/Arrow'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
/**
* Tab component that automatically scrolls the selected tab into view - used for interests
@@ -236,7 +236,7 @@ export function InterestTabs({
)
})}
</DraggableScrollView>
{isWeb && canScrollLeft && (
{IS_WEB && canScrollLeft && (
<View
style={[
a.absolute,
@@ -270,7 +270,7 @@ export function InterestTabs({
</Button>
</View>
)}
{isWeb && canScrollRight && (
{IS_WEB && canScrollRight && (
<View
style={[
a.absolute,
@@ -1,4 +1,5 @@
import {Fragment, useMemo} from 'react'
import {Text as RNText} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -9,9 +10,9 @@ import {
INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes'
import {regionName} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {atoms as a, web} from '#/alf'
import * as Select from '#/components/Select'
import {IS_WEB} from '#/env'
import {useGeolocation} from '#/geolocation'
/**
@@ -83,7 +84,7 @@ export function InternationalPhoneCodeSelect({
<Select.Item value={item.value} label={item.label}>
<Select.ItemIndicator />
<Select.ItemText style={[a.flex_1]} emoji>
{isWeb ? <Flag {...item} /> : item.unicodeFlag + ' '}
{IS_WEB ? <Flag {...item} /> : item.unicodeFlag + ' '}
{item.name}
</Select.ItemText>
<Select.ItemText style={[a.text_right]}>
@@ -100,7 +101,7 @@ export function InternationalPhoneCodeSelect({
}
function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
if (isWeb) {
if (IS_WEB) {
return (
<Image
source={svgFlag}
@@ -113,5 +114,5 @@ function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
/>
)
}
return unicodeFlag + ' '
return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText>
}
+1 -1
View File
@@ -157,7 +157,7 @@ export function Link({
to={{
screen: 'Profile',
params: {
name: labeler.creator.handle,
name: labeler.creator.did,
},
}}
label={_(
+4 -4
View File
@@ -6,7 +6,6 @@ import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
@@ -29,6 +28,7 @@ import {
} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
export function Outer({
children,
@@ -91,7 +91,7 @@ export function Content({
style={[
a.flex_1,
a.justify_center,
isIOS && align === 'platform' && a.align_center,
IS_IOS && align === 'platform' && a.align_center,
{minHeight: HEADER_SLOT_SIZE},
]}>
<AlignmentContext.Provider value={align}>
@@ -186,7 +186,7 @@ export function TitleText({
a.text_lg,
a.font_semi_bold,
a.leading_tight,
isIOS && align === 'platform' && a.text_center,
IS_IOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
style,
]}
@@ -205,7 +205,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
style={[
a.text_sm,
a.leading_snug,
isIOS && align === 'platform' && a.text_center,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>

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