Compare commits

..

95 Commits

Author SHA1 Message Date
vineyardbovines 1923b31d2d wip 2026-05-07 10:43:40 -04:00
Spence Pope 445aef8fec update locale in klipy api (#10432) 2026-05-06 18:16:06 -04:00
DS Boyce c91eae3720 Show message when chat is locked or ended (#10413) 2026-05-06 10:42:54 -07:00
DS Boyce 945d8b229d Reduce intensity of drop shadow on chat reactions (#10414) 2026-05-06 10:06:42 -07:00
Samuel Newman 7c22abcb66 [Chat] Add emoji prop to message displayname (#10431) 2026-05-06 09:39:11 -07:00
DS Boyce 26ca0a671b Improve localization and accessibility for the reactions dialog (#10409) 2026-05-06 09:36:57 -07:00
DS Boyce 276143c92b Display chat message underneath embed, not above (#10423) 2026-05-06 09:35:48 -07:00
Samuel Newman 0b6ae1cc21 Fix picking offloaded videos for IOS 26 (#10428) 2026-05-06 06:40:22 -07:00
Samuel Newman a7ed3ee3cc [Chat] Fix assumption that every chat has an owner (#10373) 2026-05-06 06:11:07 -07:00
pfrazee 2aa544fade Nightly source-language update 2026-05-06 03:17:44 +00:00
Eric Bailey 8e06c9fbb7 Add ability for users to send error reports (#10427) 2026-05-05 17:21:42 -05:00
Eric Bailey 9bf06a80a5 Add push noty script and docs (#10380) 2026-05-05 14:34:55 -05:00
DS Boyce 6f4ae14f36 Update to Node.js v24 (#10419) 2026-05-05 11:51:42 -07:00
Eric Bailey cc5b215863 Bump mod SDK and add more details to mod info dialog (#10420) 2026-05-05 13:28:06 -05:00
DS Boyce a24d30ab47 Add ability to follow from chat settings (#10415) 2026-05-05 09:51:40 -07:00
DS Boyce c21138ba63 Differentiate reactions that include the viewer (#10416) 2026-05-05 09:29:52 -07:00
Spence Pope a7fabd9e0d Refresh lightbox designs (#10330)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:25:29 -04:00
Spence Pope 48ea9e0b96 [APP-2067] Rebuild GIF Dialog (#10261)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:06:18 -04:00
pfrazee 3a997031e0 Nightly source-language update 2026-05-05 03:17:16 +00:00
DS Boyce 482fb77e36 Re-enable group invite link management (#10407) 2026-05-04 15:44:27 -07:00
DS Boyce e34080c4a0 Fix some minor issues with the invite link dialog (#10408) 2026-05-04 14:44:31 -07:00
DS Boyce 923d06229c Don't sqaure corners around emoji-only messages (#10411) 2026-05-04 14:44:08 -07:00
Eric Bailey 098ffcad3f Bump tapper/sift (#10412) 2026-05-04 16:10:41 -05:00
Samuel Newman 1b3b8e493a [Chat] Defer message send until after textinput resizes (#10405) 2026-05-04 14:05:15 -07:00
Samuel Newman dc9b9a887b [Chat] Reenable the gc gate (#10406) 2026-05-04 10:24:25 -07:00
ave 2c5d7f20b0 Remove unnecessary 90ms latency from tap events on android (#10392) 2026-05-04 05:39:26 -07:00
pfrazee 85c7cefd7c Nightly source-language update 2026-04-30 03:17:28 +00:00
DS Boyce f1764f1d61 Move useListConvoMembersQuery inside AddMembersFlow (#10386) 2026-04-29 16:49:47 +01:00
Samuel Newman 736c9625ed Fix end of feed border color (#10393) 2026-04-29 07:19:23 -07:00
DS Boyce b97cd413e3 Update CLAUDE.md with additional context and instructions (#10385) 2026-04-29 03:53:07 -07:00
Evan 8bfce09628 Fix search query autocomplete lagging behind (#10381) 2026-04-28 06:22:48 -07:00
DS Boyce 28327459e4 Enable @typescript-eslint/no-explicit-any (#10383) 2026-04-28 10:59:04 +01:00
Samuel Newman 179f3c82e7 Use tsgo for typechecking (#9474) 2026-04-28 01:44:15 -07:00
Samuel Newman 46b8a5819b Harden iOS build extraction in bundle-deploy workflow (#10372)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 10:45:15 +03:00
pfrazee 8dacc91908 Nightly source-language update 2026-04-25 03:12:59 +00:00
DS Boyce 5bf38e3721 Fix avatar bubble sizes when there's only one chat member (#10369) 2026-04-24 13:59:07 -07:00
Samuel Newman 7eec65ac78 [Chat] Temporarily turn off gate (#10368) 2026-04-24 22:45:22 +03:00
Eric Bailey cdb8d4bfb8 Group Clops Feature Branch (#10360)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:38:50 -07:00
Samuel Newman e832791367 [Chat] Improve gate (#10359) 2026-04-24 08:48:57 -05:00
Samuel Newman deb3f26d3e Fix send button disabled when sharing post via DM without text (#10355)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 05:50:08 -05:00
pfrazee a90bb66d67 Nightly source-language update 2026-04-24 03:15:36 +00:00
Eric Bailey 9762a6eef4 See if we can harden the build extraction steps (#10348) 2026-04-23 17:02:17 -05:00
Samuel Newman 7b490d8bcd Only lock viewport zoom while text inputs are focused (#10340)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-23 10:42:05 -07:00
DS Boyce 6fc31eeb48 Clean up minor issues in MessgeItem (#10338) 2026-04-23 09:30:50 -07:00
pfrazee bf371d7ecc Nightly source-language update 2026-04-23 03:15:31 +00:00
DS Boyce 9ba11baab7 Display actual number of group clip clop requests (#10333) 2026-04-22 12:31:38 -07:00
DS Boyce 8d6d4f9850 Simplify chat margins (#10334) 2026-04-22 12:31:08 -07:00
Samuel Newman 18143d051b Bump version from 1.121.0 to 1.122.0 (#10336) 2026-04-22 22:04:51 +03:00
DS Boyce 38c8adcc27 Fix some minor chat issues (#10332) 2026-04-22 10:18:01 -07:00
DS Boyce dcc06a90a0 Display sender name in last message for group clip clops (#10320)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-22 10:03:53 -07:00
Eric Bailey c7e9efbf99 Prep release 1.121.0 (#10331) 2026-04-22 11:04:03 -05:00
Samuel Newman 1c38665d4c Replace lande with native language detection (#9974)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-22 10:26:14 -05:00
pfrazee 5d40532aa9 Nightly source-language update 2026-04-22 03:14:44 +00:00
Samuel Newman abe0ca521d Fix slow re-renders when toggling content languages (#10128)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:16:26 -05:00
DS Boyce 07344f70fc Fix missing recId values for suggested user events (#10291)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-21 15:59:13 -05:00
Samuel Newman bfdaab0a14 [Chat] Workaround for autocomplete suggestion bug on iOS (#10321) 2026-04-21 15:56:07 -05:00
Samuel Newman 3153ea4302 Refactor minimal shell mode to refcounting (#10319) 2026-04-21 13:04:02 -07:00
Samuel Newman 6d53459e92 [Chat] Gate groupchat screen (#10316) 2026-04-21 11:21:19 -07:00
DS Boyce 2ab1e2c9e9 Display system messages in chats (#10312)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-21 10:43:22 -07:00
Samuel Newman 0df1d6f53e [Chat] Message context menu fixes (#10314) 2026-04-21 09:24:46 -07:00
Samuel Newman 18052f08e0 Update claude workflow to 4.7 (#10315) 2026-04-21 06:19:52 -07:00
Spence Pope 3931b90818 don't screenshot dictation placeholders as pasted images (#10313) 2026-04-20 23:35:22 -07:00
pfrazee 985129dd34 Nightly source-language update 2026-04-21 03:14:34 +00:00
DS Boyce 8c2e4c6fad Write chat declaration record in response to different events (#10216)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-20 15:39:12 -05:00
DS Boyce d58ff89441 Require age assurance to access chat settings (#10053) 2026-04-20 15:34:32 -05:00
Samuel Newman 014ffac903 Delete "non-standard" styles from pal (#10279) 2026-04-20 11:34:25 -07:00
Samuel Newman ae0c2e8697 [Web] Scale animation when clicking on images (#10305) 2026-04-20 13:09:35 -05:00
Samuel Newman b8cabfaae6 Skip empty posts when publishing threads (#10307)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:33:38 -07:00
Spence Pope 444c5787c5 [APP-2037] add bsky video (#10281)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-20 07:21:25 -07:00
surfdude29 2798e98c9c Add context for translators and tweak strings (#10280) 2026-04-20 07:06:18 -07:00
Samuel Newman 9778a3da16 Remove decelerationRate from embed gallery FlatList (#10298)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 06:54:25 -07:00
Samuel Newman 52b8201d2f Add READMEs to modules (#10306) 2026-04-20 06:53:04 -07:00
Thomas May dddc022747 nit: fix perf regression in hotkeys (#10302)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-20 06:15:05 -07:00
pfrazee 5df51bdea7 Nightly source-language update 2026-04-19 03:14:38 +00:00
Samuel Newman bc3672ceeb [Chat] Remove convoState dependency from header (#10293) 2026-04-18 12:58:16 -07:00
pfrazee 35411e88c9 Nightly source-language update 2026-04-18 03:13:09 +00:00
DS Boyce 935347c73d Use convo view instead of convo state for driving the UI (#10290) 2026-04-17 15:23:44 -07:00
DS Boyce feebc6a98b Fix layout issue with avatar bubbles (#10289) 2026-04-17 13:50:41 -07:00
Eric Bailey 587dc8dfe8 Squash carousel in chat messages, fix rounding (#10286) 2026-04-17 14:24:42 -05:00
Samuel Newman 43108533eb [Chat] Fix embed offset (#10285) 2026-04-17 11:44:48 -07:00
DS Boyce adca192f3a Add ability to edit group clip clop name (#10275) 2026-04-17 11:44:34 -07:00
Samuel Newman 591504307d [Chat] Minor bugfixes (#10284) 2026-04-17 10:50:52 -07:00
DS Boyce bc9ad2c2d9 Fix crash when deleting account from NoAccessScreen (#10283) 2026-04-17 10:48:08 -07:00
DS Boyce 9e9ff70682 Enable access for group clip clops with no messages (#10276) 2026-04-17 10:40:37 -07:00
Samuel Newman 226a321a27 Fix GrowthBook cache breaking on cold start (#10282)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:12:12 -07:00
Samuel Newman e58feaeb0f Animate border radius in lightbox transition (#10272) 2026-04-17 05:45:56 -07:00
Spence Pope a77b6e3525 [APP-2066] Migrate from Tenor to KLIPY (#10240)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:43:13 -04:00
Samuel Newman a97b15b204 EmojiPicker component (#10249) 2026-04-17 02:42:10 -07:00
pfrazee 8f56fca82c Nightly source-language update 2026-04-17 03:14:49 +00:00
DS Boyce 524cbc514d Don't toggle the date divider when tapping a reaction (#10274) 2026-04-16 13:06:09 -07:00
Samuel Newman 3358e1947b Lazily measure lightbox thumbnails (#10270) 2026-04-16 12:24:11 -07:00
DS Boyce 6e3c9c3a9f Add flow for adding people to a group clip clop (#10255) 2026-04-16 11:55:19 -07:00
DS Boyce ac68cfe98c Remove own reaction from reactions dialog on tap (#10231)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-16 11:16:43 -07:00
DS Boyce 36c95d7dc6 Remove console log (#10273) 2026-04-16 10:57:48 -07:00
DS Boyce 9f3c21e298 Add header to group clip clops (#10254)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-16 10:48:30 -07:00
354 changed files with 106631 additions and 49803 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(yarn typecheck *)",
"Bash(yarn lint *)",
"Bash(yarn test *)"
]
}
}
+26 -3
View File
@@ -109,23 +109,46 @@ jobs:
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
mkdir ios-build
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: 📚 Get version from package.json
id: get-build-info
+44 -2
View File
@@ -239,10 +239,52 @@ jobs:
yarn use-build-number-with-bump
eas build -p ios
--profile testflight
--local --output build.ipa --non-interactive
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
run: eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
yarn sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: ⬇️ Restore Cache
id: get-base-commit
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
--model claude-opus-4-7
+25
View File
@@ -16,6 +16,31 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Verify Node version pins match .nvmrc
run: |
set -euo pipefail
expected=$(tr -d '[:space:]' < .nvmrc)
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z — service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z — Go images that nvm-install Node for the JS build stage
for f in Dockerfile Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# eas.json "node": "X.Y.Z"
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "eas.json" "$v"
exit $rc
- name: Install node
uses: actions/setup-node@v6
with:
+6
View File
@@ -86,6 +86,9 @@ stats.json
# VSCode
.vscode
# Zed
.zed
# gitignore and github actions
!.gitignore
!.github
@@ -127,3 +130,6 @@ bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
+1 -1
View File
@@ -1 +1 @@
20
24.15.0
+165 -81
View File
@@ -1,4 +1,4 @@
# CLAUDE.md - Bluesky Social App Development Guide
# CLAUDE.md  Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
@@ -7,13 +7,17 @@ This document provides guidance for working effectively in the Bluesky Social ap
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React 19.1
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TypeScript 6
- React Navigation 7 for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Lingui 5 for internationalization
- Custom design system called ALF (Application Layout Framework)
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
## Essential Commands
```bash
@@ -162,6 +166,10 @@ its own looks more like a single component file.
### Documentation and Tests Within Features
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
@@ -182,6 +190,14 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
### Basic Usage
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```tsx
import {atoms as a, useTheme} from '#/alf'
@@ -190,9 +206,7 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
</View>
)
}
@@ -200,20 +214,23 @@ function MyComponent() {
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
**Static Atoms**  Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
**Theme Atoms**  Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
**Platform Utilities**  For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
@@ -225,7 +242,8 @@ const styles = [
]
```
**Breakpoints** - Responsive design:
**Breakpoints**  Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
@@ -245,6 +263,38 @@ if (gtMobile) {
## Component Patterns
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
- Prefer functions over arrow functions for component declarations.
- Prefer prop destructuring via parameters over a const within the component.
- Prefer inline types over `Props` types or interfaces.
- Set reasonable defaults for optional props.
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>{index}</Text>
<Text>{foo}</Text>
</Fragment>
))}
</View>
</>
);
}
```
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
@@ -263,23 +313,29 @@ function MyFeature() {
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
<DialogInner />
</Dialog.Outer>
</>
)
}
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</>
)
}
```
### Menu Component
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
@@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField'
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one  two` over `one—two`.
```tsx
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
function MyComponent() {
const {_} = useLingui()
const {t: l} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Simple strings - use the l macro
const title = l`Settings`
const errorMessage = l({
message: 'Something went wrong',
comment: 'Generic error message for unknown/unhandled errors.',
context: 'Toast',
})
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
const greeting = l`Hello, ${name}!`
// Pluralization
const countLabel = _(plural(count, {
const countLabel = plural(count, {
one: '# item',
other: '# items',
}))
})
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
<Trans>
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
</Trans>
</Text>
)
}
```
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
```tsx
import {useLingui} from '@lingui/react/macro'
function MyComponent() {
const {i18n} = useLingui()
const createdAt = new Date()
return i18n.date(createdAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})
}
```
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
@@ -473,7 +557,7 @@ export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
@@ -481,7 +565,7 @@ export function useProfileMutation() {
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: (error) => {
onError: error => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
@@ -490,7 +574,7 @@ export function useProfileMutation() {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
}
},
})
}
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
@@ -565,12 +653,7 @@ function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
}
```
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
}
// Programmatic navigation
@@ -637,8 +716,9 @@ Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
- `src/components/Dialog/index.tsx Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
@@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase:
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
@@ -720,12 +801,13 @@ const onConfirm = () => {
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
The Menu component on iOS specifically uses this pattern  see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
- `Dialog.Handle Only renders on native (drag handle for bottom sheet)
- `Dialog.Close Only renders on web (X button)
- `Menu.Divider Only renders on web
- `Menu.ContainerItem Only works on native
Always test on multiple platforms when using these components.
@@ -772,6 +855,7 @@ const handlePress = () => {
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
@@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
| Purpose | Location |
| ----------------- | -------------------------------------------- |
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
+1 -1
View File
@@ -7,7 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
#
# Node
#
ENV NODE_VERSION=20
ENV NODE_VERSION=24.15.0
ENV NVM_DIR=/usr/share/nvm
#
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:20.11-alpine3.18 as build
FROM node:24.15.0-alpine3.22 AS build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
FROM node:24.15.0-alpine3.22
RUN apk add --update dumb-init
@@ -29,8 +29,6 @@ VOLUME /app/data
EXPOSE 3000
ENV LINK_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:20.11-alpine3.18 as build
FROM node:24.15.0-alpine3.22 AS build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
FROM node:24.15.0-alpine3.22
RUN apk add --update dumb-init
@@ -30,8 +30,6 @@ EXPOSE 3000 3001
ENV CARD_PORT=3000
ENV CARD_METRICS_PORT=3001
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=20
ENV NODE_VERSION=24.15.0
ENV NVM_DIR=/usr/share/nvm
# Go
+56 -7
View File
@@ -8,7 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -450,6 +450,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
]
const outputs = [
@@ -845,6 +852,35 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1035,17 +1071,30 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
describe('tenorUrlToBskyGifUrl', () => {
describe('klipyUrlToBskyGifUrl', () => {
const inputs = [
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
]
it.each(inputs)(
'returns url with t.gifs.bsky.app as hostname for input url',
'returns url with k.gifs.bsky.app as hostname for input url',
input => {
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
const out = klipyUrlToBskyGifUrl(input)
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
},
)
it('preserves the path and query params when rewriting', () => {
const out = klipyUrlToBskyGifUrl(
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
expect(out).toEqual(
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
})
it('returns empty string for invalid URLs', () => {
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
})
})
+2
View File
@@ -66,6 +66,7 @@ module.exports = function (_config) {
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -123,6 +124,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

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

After

Width:  |  Height:  |  Size: 807 B

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 502 B

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

After

Width:  |  Height:  |  Size: 376 B

+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"main": "index.ts",
"scripts": {
"test": "npm run test:unit && npm run test:e2e",
"test": "yarn run test:unit && yarn run test:e2e",
"test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test:unit": "node --loader ts-node/esm --test ./src/*.test.ts",
"build": "tsc"
+10 -6
View File
@@ -3,11 +3,14 @@
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"engines": {
"node": ">=24.15.0"
},
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"start": "tsx ./src/bin.ts",
"dev": "tsx watch --clear-screen=false ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
"install-fonts": "node scripts/install-fonts.ts"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
@@ -24,8 +27,9 @@
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"@types/node": "^24.12.2",
"@types/react": "^18.3.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}
+7 -4
View File
@@ -20,10 +20,13 @@ export class AppContext {
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
private opts: AppContextOptions
constructor(opts: AppContextOptions) {
this.opts = opts
this.cfg = opts.cfg
this.appviewAgent = opts.appviewAgent
this.fonts = opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
+7 -4
View File
@@ -19,10 +19,13 @@ export class CardService {
private terminator?: HttpTerminator
private metricsTerminator?: HttpTerminator
constructor(
public app: express.Application,
public ctx: AppContext,
) {}
public app: express.Application
public ctx: AppContext
constructor(app: express.Application, ctx: AppContext) {
this.app = app
this.ctx = ctx
}
static async create(cfg: Config): Promise<CardService> {
let app = express()
+10 -4
View File
@@ -38,11 +38,11 @@ export default function (ctx: AppContext, app: Express) {
}
const imageEntries = await Promise.all(
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
.concat((starterPack.listItemsSample ?? []).map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.filter(p => !p.labels?.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
@@ -58,7 +58,12 @@ export default function (ctx: AppContext, app: Express) {
}),
)
const images = new Map(
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
imageEntries
.filter(
(entry): entry is readonly [string, Buffer<ArrayBuffer>] =>
entry[1] !== null,
)
.slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
@@ -68,8 +73,9 @@ export default function (ctx: AppContext, app: Express) {
width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
return (await loadEmojiAsSvg(text)) ?? ''
}
return ''
},
},
)
+4 -1
View File
@@ -3,9 +3,12 @@
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"erasableSyntaxOnly": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
"rootDir": "./src",
"outDir": "dist",
"skipLibCheck": true
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
+218 -117
View File
@@ -90,12 +90,135 @@
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f"
integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@esbuild/aix-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
"@esbuild/android-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
"@esbuild/android-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
"@esbuild/android-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
"@esbuild/darwin-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322"
integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
"@esbuild/darwin-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
"@esbuild/freebsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
"@esbuild/freebsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
"@esbuild/linux-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
"@esbuild/linux-arm@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
"@esbuild/linux-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
"@esbuild/linux-loong64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
"@esbuild/linux-mips64el@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
"@esbuild/linux-ppc64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
"@esbuild/linux-riscv64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
"@esbuild/linux-s390x@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
"@esbuild/linux-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
"@esbuild/netbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
"@esbuild/netbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
"@esbuild/openbsd-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
"@esbuild/openbsd-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
"@esbuild/openharmony-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
"@esbuild/sunos-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
"@esbuild/win32-arm64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
"@esbuild/win32-ia32@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
"@esbuild/win32-x64@0.27.7":
version "0.27.7"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
"@ipld/dag-cbor@^7.0.3":
version "7.0.3"
@@ -105,24 +228,6 @@
cborg "^1.6.0"
multiformats "^9.5.4"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@opentelemetry/api@^1.4.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
@@ -214,26 +319,6 @@
fflate "^0.7.3"
string.prototype.codepointat "^0.2.1"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==
"@tsconfig/node12@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
"@tsconfig/node14@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node16@^1.0.2":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@types/body-parser@*":
version "1.19.6"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
@@ -286,12 +371,17 @@
dependencies:
undici-types "~7.12.0"
"@types/node@^20.14.3":
version "20.14.3"
resolved "https://registry.npmjs.org/@types/node/-/node-20.14.3.tgz"
integrity sha512-Nuzqa6WAxeGnve6SXqiPAM9rA++VQs+iLZ1DDd56y0gdvygSZlQvZuvdFPR3yLqkVxPu4WrO02iDEyH1g+wazw==
"@types/node@^24.12.2":
version "24.12.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.12.2.tgz#353cb161dbf1785ea25e8829ba7ec574c5c629ac"
integrity sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==
dependencies:
undici-types "~5.26.4"
undici-types "~7.16.0"
"@types/prop-types@*":
version "15.7.15"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
"@types/qs@*":
version "6.14.0"
@@ -303,6 +393,14 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react@^18.3.0":
version "18.3.28"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781"
integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==
dependencies:
"@types/prop-types" "*"
csstype "^3.2.2"
"@types/send@*":
version "0.17.5"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
@@ -335,23 +433,6 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-walk@^8.1.1:
version "8.3.3"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e"
integrity sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==
dependencies:
acorn "^8.11.0"
acorn@^8.11.0, acorn@^8.4.1:
version "8.12.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
@@ -521,11 +602,6 @@ cookie@0.7.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
css-background-parser@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz"
@@ -550,6 +626,11 @@ css-to-react-native@^3.0.0:
css-color-keywords "^1.0.0"
postcss-value-parser "^4.0.2"
csstype@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
@@ -586,11 +667,6 @@ detect-libc@^2.0.1:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -644,6 +720,38 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
dependencies:
es-errors "^1.3.0"
esbuild@~0.27.0:
version "0.27.7"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f"
integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==
optionalDependencies:
"@esbuild/aix-ppc64" "0.27.7"
"@esbuild/android-arm" "0.27.7"
"@esbuild/android-arm64" "0.27.7"
"@esbuild/android-x64" "0.27.7"
"@esbuild/darwin-arm64" "0.27.7"
"@esbuild/darwin-x64" "0.27.7"
"@esbuild/freebsd-arm64" "0.27.7"
"@esbuild/freebsd-x64" "0.27.7"
"@esbuild/linux-arm" "0.27.7"
"@esbuild/linux-arm64" "0.27.7"
"@esbuild/linux-ia32" "0.27.7"
"@esbuild/linux-loong64" "0.27.7"
"@esbuild/linux-mips64el" "0.27.7"
"@esbuild/linux-ppc64" "0.27.7"
"@esbuild/linux-riscv64" "0.27.7"
"@esbuild/linux-s390x" "0.27.7"
"@esbuild/linux-x64" "0.27.7"
"@esbuild/netbsd-arm64" "0.27.7"
"@esbuild/netbsd-x64" "0.27.7"
"@esbuild/openbsd-arm64" "0.27.7"
"@esbuild/openbsd-x64" "0.27.7"
"@esbuild/openharmony-arm64" "0.27.7"
"@esbuild/sunos-x64" "0.27.7"
"@esbuild/win32-arm64" "0.27.7"
"@esbuild/win32-ia32" "0.27.7"
"@esbuild/win32-x64" "0.27.7"
escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
@@ -810,6 +918,11 @@ fs-extra@^8.0.1:
jsonfile "^4.0.0"
universalify "^0.1.0"
fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -850,6 +963,13 @@ get-proto@^1.0.1:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
get-tsconfig@^4.7.5:
version "4.14.0"
resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8"
integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==
dependencies:
resolve-pkg-maps "^1.0.0"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz"
@@ -990,11 +1110,6 @@ loose-envify@^1.1.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -1273,6 +1388,11 @@ real-require@^0.2.0:
resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz"
integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==
resolve-pkg-maps@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
roarr@^7.0.4:
version "7.21.1"
resolved "https://registry.npmjs.org/roarr/-/roarr-7.21.1.tgz"
@@ -1515,24 +1635,15 @@ toidentifier@1.0.1:
resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
ts-node@^10.9.2:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
tsx@^4.21.0:
version "4.21.0"
resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1"
integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
esbuild "~0.27.0"
get-tsconfig "^4.7.5"
optionalDependencies:
fsevents "~2.3.3"
twemoji-parser@14.0.0:
version "14.0.0"
@@ -1562,10 +1673,10 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@^5.4.5:
version "5.4.5"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz"
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
typescript@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
uint8arrays@3.0.0:
version "3.0.0"
@@ -1574,16 +1685,16 @@ uint8arrays@3.0.0:
dependencies:
multiformats "^9.4.2"
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~7.12.0:
version "7.12.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.12.0.tgz#15c5c7475c2a3ba30659529f5cdb4674b622fafb"
integrity sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
unicode-trie@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz"
@@ -1612,21 +1723,11 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
vary@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
yoga-wasm-web@^0.3.3:
version "0.3.3"
resolved "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz"
+1 -1
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin">
<!--
Preconnect to essential domains
+1 -1
View File
@@ -6,7 +6,7 @@
},
"build": {
"base": {
"node": "20.19.4"
"node": "24.15.0"
},
"development": {
"extends": "base",
+742
View File
@@ -0,0 +1,742 @@
{
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/PassiveAnalytics.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metadata.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/Button.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Composer/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Dialog/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/DraggableList/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/FeedCard.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/FocusScope/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/InternationalPhoneCodeSelect.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Layout/const.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Lists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Menu/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Menu/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Portal.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/PostControls/BookmarkButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/ProfileHoverCard/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/Select/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/Select/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/StarterPack/ProfileStarterPacks.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/DeviceLocationRequestDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/EmailDialog/components/ResendEmailText.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/GifSelect.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/LanguageSelectDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/MutedWords.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialogs/PostInteractionSettingsDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/SegmentedControl.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/forms/ToggleButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/hooks/useFollowMethods.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/AutoSizedImage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/images/Gallery/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/images/Gallery/useKeyboardHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/Gallery/usePointerHandlers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/components/images/ImageLayoutGrid.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/images/ImageLayoutGridItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/moderation/ReportDialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/features/liveNow/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/geolocation/service.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/ScrollContext.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/lib/api/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/lib/async/retry.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/async/until.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/broadcast/stub.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/functions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/lib/getUserDisplayName.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useAccountSwitcher.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useCleanError.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useNonReactiveCallback.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useRequireEmailVerification.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useToggleMutationQueue.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/hooks/useWebScrollRestoration.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/lib/international-telephone-codes.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/manip.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ios.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/media/save-image.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/merge-refs.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/react-query.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/router.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/routes/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/lib/strings/errors.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/locale/i18n.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/platform/polyfills.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Bookmarks/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Deactivated.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ChooseAccountForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/ForgotPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/LoginForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Login/SetNewPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Moderation/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/ModerationInteractionSettings/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepFinished/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Onboarding/StepInterests/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/PostThread/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Profile/Header/EditProfileDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/screens/Profile/Header/ProfileHeaderLabeler.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Profile/Header/Shell.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/Sections/Feed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Profile/components/GermButton.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/FindContactsSettings.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/ChangePasswordDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Settings/components/DeactivateAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Settings/components/DeleteAccountDialog.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/screens/Signup/StepInfo/Policies.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/SignupQueued.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/cache/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/feed-feedback.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/messages/convo/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/messages/events/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/persisted/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/index.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/persisted/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/notifications/feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/nuxs/definitions.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/pinned-post.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/post-feed.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/state/queries/postgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/state/queries/threadgate/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/queries/util.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/state/shell/progress-guide.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/storage/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 8
}
},
"src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/composer/photos/OpenCameraBtn.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/feeds/ProfileFeedgens.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lightbox/ImageViewing/@types/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lightbox/ImageViewing/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/lists/ListMembers.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/lists/MyLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/lists/ProfileLists.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/notifications/NotificationFeedItem.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/Pager.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/view/com/pager/PagerWithHeader.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/view/com/pager/PagerWithHeader.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/pager/TabBar.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/FeedShutdownMsg.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/posts/PostFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EmptyState.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/ErrorBoundary.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/EventStopper.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/Link.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/List.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/List.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 14
}
},
"src/view/com/util/MainScrollProvider.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/view/com/util/ViewSelector.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
}
},
"src/view/com/util/Views.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/com/util/fab/FABInner.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/Debug.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/screens/DebugMod.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/view/shell/createNativeStackNavigatorWithAuth.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
}
}
+2 -1
View File
@@ -32,6 +32,7 @@ export default defineConfig(
'*.html',
'bskyweb/**',
'bskyembed/**',
'bskyogcard/**',
'src/locale/locales/_build/**',
'src/locale/locales/**/*.js',
'*.e2e.ts',
@@ -229,7 +230,7 @@ export default defineConfig(
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
+7 -3
View File
@@ -61,9 +61,13 @@ jest.mock('expo-media-library', () => ({
usePermissions: jest.fn(() => [true]),
}))
jest.mock('lande', () => ({
__esModule: true, // this property makes it work
default: jest.fn().mockReturnValue([['eng']]),
jest.mock('@bsky.app/expo-guess-language', () => ({
guessLanguageSync: jest
.fn()
.mockReturnValue([{language: 'en', confidence: 1}]),
guessLanguageAsync: jest
.fn()
.mockResolvedValue([{language: 'en', confidence: 1}]),
}))
jest.mock('sentry-expo', () => ({
+134
View File
@@ -0,0 +1,134 @@
# BlueskyClip
An iOS App Clip implementation for Bluesky starter packs. App Clips are lightweight app experiences that allow users to preview and join Bluesky through starter packs without installing the full app.
## What It Does
BlueskyClip provides a minimal, on-demand iOS app experience for viewing and joining Bluesky starter packs. When a user encounters a starter pack link (e.g., `bsky.app/start/...` or `go.bsky.app/...`), iOS can present the App Clip instead of requiring a full app install. The App Clip:
1. Loads the starter pack web page in a WKWebView
2. Allows users to browse the starter pack content
3. Presents the App Store overlay when the user decides to join
4. Passes the starter pack URI to the main app via shared UserDefaults
## Architecture
### Native iOS Implementation
The App Clip is a standalone iOS target with its own minimal Swift implementation:
- **AppDelegate.swift**: Standard app delegate that sets up the view controller and handles URL routing (both direct URL opens and universal links)
- **ViewController.swift**: Main view controller that manages the WKWebView, detects starter pack URLs, and communicates with the web layer
### Communication Flow
```
User taps starter pack link
iOS presents BlueskyClip App Clip
WKWebView loads bsky.app with ?clip=true parameter
Web app detects clip mode and sends actions via postMessage
ViewController receives messages and:
- Presents App Store overlay (action: "present")
- Stores starter pack URI in shared UserDefaults (action: "store")
User downloads main app
Main app reads starterPackUri from shared UserDefaults
Main app displays starter pack onboarding flow
```
### Key Implementation Details
**URL Detection** (`isStarterPackUrl`):
- Matches `bsky.app/start/*` and `bsky.app/starter-pack/*` paths (4 path components)
- Matches short links `go.bsky.app/*` (2 path components)
**WebView Communication** (`WKScriptMessageHandler`):
- Listens for messages on the "onMessage" channel
- Handles two action types:
- `present`: Shows the App Store overlay using `SKOverlay`
- `store`: Writes JSON data to shared UserDefaults with the specified key
**Data Sharing**:
- Uses UserDefaults suite `group.app.bsky` (App Group)
- Primary key: `starterPackUri` - stores the starter pack URL
- The main app reads this value on launch via `SharedPrefs.getString('starterPackUri')` (see `src/components/hooks/useStarterPackEntry.native.ts`)
## Configuration
### Build Configuration
The App Clip target is automatically configured via Expo config plugins located in `/plugins/starterPackAppClipExtension/`:
- **withStarterPackAppClip.js**: Main plugin that orchestrates all configuration
- **withXcodeTarget.js**: Creates the App Clip target in Xcode with proper build settings
- **withAppEntitlements.js**: Configures main app entitlements for App Clip association
- **withClipEntitlements.js**: Sets up App Clip entitlements (App Groups, parent app identifier, associated domains)
- **withClipInfoPlist.js**: Generates the Info.plist for the App Clip target
- **withFiles.js**: Copies Swift source files and assets from `modules/BlueskyClip/` to the iOS build directory
### Entitlements
**Main App** (`app.entitlements`):
- `com.apple.security.application-groups`: `group.app.bsky`
- `com.apple.developer.associated-appclip-app-identifiers`: Links to the App Clip bundle ID
**App Clip** (`BlueskyClip.entitlements`):
- `com.apple.security.application-groups`: `group.app.bsky` (for data sharing)
- `com.apple.developer.parent-application-identifiers`: Links to the main app bundle ID
- `com.apple.developer.associated-domains`: Inherits from main app config (for universal links)
### Build Settings
- Deployment target: iOS 15.1+
- Bundle ID: `[main-app-bundle-id].AppClip`
- Product type: `com.apple.product-type.application.on-demand-install-capable`
- Development team: `B3LX46C5HS`
- Device family: iPhone only (1)
## Platform Support
- **iOS**: Full support via native App Clip
- **Android**: Not applicable (no App Clip equivalent)
- **Web**: Not applicable (web uses standard starter pack landing pages)
## Integration with Main App
The main app detects App Clip-originated starter packs through `useStarterPackEntry` hook:
**Native** (`src/components/hooks/useStarterPackEntry.native.ts`):
- Reads `starterPackUri` from `SharedPrefs` (App Group)
- Clears the value after reading to prevent re-use
- Sets active starter pack in app state
**Web** (`src/components/hooks/useStarterPackEntry.ts`):
- Detects `?clip=true` URL parameter
- Extracts starter pack URI from URL
- Sets active starter pack with `isClip: true` flag
## Files
```
modules/BlueskyClip/
├── AppDelegate.swift # App lifecycle and URL handling
├── ViewController.swift # WebView management and message handling
└── Images.xcassets/ # App Clip icon assets
├── AppIcon.appiconset/
│ ├── App-Icon-1024x1024@1x.png
│ └── Contents.json
└── Contents.json
```
## Development Notes
- The App Clip is built as part of the main Xcode project when running `yarn prebuild`
- Source files are copied during the prebuild process, not directly referenced
- Changes to Swift files require running `yarn prebuild` to take effect
- The App Clip shares the same version number as the main app
- App Clips have a 15MB size limit (enforced by Apple)
- Users can convert an App Clip session into a full app install without losing data (via shared App Group)
+7
View File
@@ -8,6 +8,13 @@
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
+84 -7
View File
@@ -1,5 +1,6 @@
import UserNotifications
import UIKit
import Intents
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
}
self.bestAttempt = bestAttempt
if reason == "chat-message" {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else {
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
}
// Any image downloading (or other network tasks) should be handled at the end
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
// gets called, we might not have all the needed mutations completed in time.
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttempt)
}
// MARK: Communication Notification
func createCommunicationNotification(
from content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage? = nil
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
let senderHandleValue = userInfo["senderHandle"] as? String
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
let sender = INPerson(
personHandle: senderHandle,
nameComponents: nil,
displayName: senderDisplayName,
image: avatarImage,
contactIdentifier: nil,
customIdentifier: nil
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
return try content.updating(from: intent)
} catch {
return content
}
}
func downloadAvatarImage(from urlString: String) -> INImage? {
let thumbnailUrlString = urlString.replacingOccurrences(
of: "/img/avatar/",
with: "/img/avatar_thumbnail/"
)
guard let url = URL(string: thumbnailUrlString) else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data? = nil
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
}
task.resume()
semaphore.wait()
guard let data = imageData else { return nil }
return INImage(imageData: data)
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
+135
View File
@@ -0,0 +1,135 @@
# BlueskyNSE
BlueskyNSE is an iOS Notification Service Extension that processes push notifications before they are displayed to the user. NSE stands for "Notification Service Extension", a native iOS app extension type.
## What It Does
This extension intercepts incoming push notifications and performs processing before displaying them:
1. Manages badge counts for app icon
2. Applies custom notification sounds based on user preferences
3. Enables notification customization without requiring the main app to be running
## How It Works
When a push notification arrives on iOS, the system can invoke this extension to modify the notification content before displaying it. The extension runs in a separate process from the main app and has strict time limits (approximately 30 seconds) to complete its work.
### Architecture
The extension uses shared UserDefaults (via App Groups) to access preferences set by the main app:
- **App Group**: `group.app.bsky` allows data sharing between the main app and the extension
- **Shared Preferences**: Stored in UserDefaults suite accessible by both processes
- **Thread Safety**: Uses a dedicated serial DispatchQueue (`NSEPrefsQueue`) to prevent race conditions when multiple notifications arrive simultaneously
### Notification Processing Flow
1. System receives push notification
2. `NotificationService.didReceive()` is called
3. Extension creates mutable copy of notification content
4. Based on notification type (determined by `reason` field):
- **Chat messages** (`reason == "chat-message"`): Applies custom DM sound if user preference `playSoundChat` is enabled
- **Other notifications**: Increments and applies badge count
5. Extension delivers modified notification to system via `contentHandler`
### Badge Count Management
Badge counts are managed centrally by the extension:
- Each non-chat notification increments the badge count
- Count is synchronized across notification instances using the serial queue
- Main app can reset the count via the `expo-background-notification-handler` module
### Notification Sounds
Two sound types are supported:
- **Default system sound**: Standard iOS notification sound
- **DM sound**: Custom `dm.aiff` sound file for chat messages
DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
## Key Files
| File | Purpose |
|------|---------|
| `NotificationService.swift` | Main service extension implementation |
| `BlueskyNSE.entitlements` | iOS entitlements configuration for App Group access |
| `Info.plist` | Extension metadata and configuration |
### NotificationService.swift
Contains two main classes:
**NotificationService**: The main extension class that implements `UNNotificationServiceExtension`
- `didReceive(_:withContentHandler:)`: Processes incoming notifications
- `serviceExtensionTimeWillExpire()`: Handles timeout scenarios
- Mutation methods for modifying notification content
**NSEUtil**: Singleton utility class for shared state management
- Provides shared `UserDefaults` instance for the App Group
- Manages serial queue for thread-safe preference access
- Helper methods for notification content manipulation
## Configuration
### App Group Setup
The extension requires the `group.app.bsky` App Group to be configured in:
1. Main app target capabilities
2. Extension target capabilities (defined in `BlueskyNSE.entitlements`)
### Shared Preferences
The following preferences are shared between the main app and extension:
| Preference Key | Type | Purpose |
|----------------|------|---------|
| `badgeCount` | Int | Current badge count for app icon |
| `playSoundChat` | Bool | Whether to play sound for chat notifications |
These are managed by the `expo-background-notification-handler` module in the main app.
### Sound Files
The custom DM sound file (`dm.aiff`) must be included in the extension's bundle. The iOS project configuration handles copying this resource during the build.
## Platform Support
- **iOS**: Fully supported (primary platform for this extension)
- **Android**: Not applicable (Android uses different notification handling mechanisms)
- **Web**: Not applicable (web notifications are handled by browser APIs)
## Integration with Main App
The extension coordinates with the main app through:
1. **expo-background-notification-handler** module: Provides JavaScript API for managing shared preferences
2. **App Group shared storage**: Enables data synchronization between processes
3. **Push notification payload**: Must include `reason` field to determine notification type
### Setting User Preferences
Users can control notification sounds via the Chat Settings screen (`src/screens/Messages/Settings.tsx`):
```typescript
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
const {preferences, setPref} = useBackgroundNotificationPreferences()
setPref('playSoundChat', true) // Enable DM sounds
```
## Limitations
1. **Time constraints**: Extension must complete processing within ~30 seconds or the system will terminate it
2. **Process isolation**: Runs in separate process with limited memory and resources
3. **iOS only**: Notification Service Extensions are an iOS-specific feature
4. **Concurrent processing**: Multiple notifications may arrive simultaneously, requiring careful state management
## Best Practices
When modifying this extension:
1. Keep processing fast and synchronous when possible
2. Use the shared serial queue for any UserDefaults mutations
3. Avoid network requests that could cause timeouts
4. Always call `contentHandler` with modified content, even on errors
5. Test with multiple concurrent notifications to verify thread safety
+140
View File
@@ -0,0 +1,140 @@
# Share-with-Bluesky
iOS Share Extension for the Bluesky Social app that enables users to share content from other apps directly to Bluesky.
## Overview
This module implements an iOS Share Extension (Action Extension) that appears in the system share sheet when users tap the share button in other iOS apps. It allows sharing text, URLs, images, and videos to create a new Bluesky post.
## Features
- Share plain text
- Share URLs (web links)
- Share images (up to 4 images, supports PNG, JPG, JPEG, GIF, HEIC)
- Share videos (single video, supports MOV, MP4, M4V)
- Automatic image dimension extraction
- Automatic video dimension extraction
- App group file sharing for media access
## Architecture
### iOS Share Extension
The extension is implemented as a native iOS Share Extension using Swift. When a user shares content:
1. The `ShareViewController` receives the shared content from the extension context
2. Content is processed based on its type (text, URL, image, or video)
3. Media files are copied to a shared App Group container (`group.app.bsky`) for access by the main app
4. Image and video dimensions are extracted and encoded into the URI
5. The extension constructs a deep link URL with the content encoded in query parameters
6. The main Bluesky app is opened with the deep link
7. The extension completes and dismisses
### Deep Link Format
The extension communicates with the main app using deep links with the `bluesky://` scheme:
```
bluesky://intent/compose?text=<encoded-text>
bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>
bluesky://intent/compose?videoUri=<uri>|<width>|<height>
```
The scheme can be customized by setting the `MainAppScheme` key in `Info.plist` to support forks.
### Main App Integration
The main app handles these deep links in `src/lib/hooks/useIntentHandler.ts`:
- Parses the deep link parameters
- Validates image/video URIs for security (filters out external URLs)
- Opens the composer with the pre-populated content
- Supports up to 4 images or 1 video per share
## Key Files
### Module Files
- `ShareViewController.swift` - Main view controller that handles share requests and processes content
- `Info.plist` - Extension configuration (activation rules, supported content types)
- `Share-with-Bluesky.entitlements` - App group entitlements for shared file access
### App Integration
- `src/lib/hooks/useIntentHandler.ts` - Main app hook that handles incoming deep links
- `android/app/src/main/AndroidManifest.xml` - Android share intent configuration (lines 57-76)
## Configuration
### Supported Content Types
Defined in `Info.plist` under `NSExtensionActivationRule`:
- Text: Plain text strings
- Web URLs: Up to 1 URL
- Images: Up to 10 images
- Videos: Up to 1 video
### App Group
The extension uses the `group.app.bsky` App Group identifier to share files with the main app. This is configured in:
- `Share-with-Bluesky.entitlements`
- Main app's entitlements file
### Custom Scheme
The `MainAppScheme` in `Info.plist` defaults to `bluesky` but can be changed for forks to use a custom URL scheme.
## Platform Support
- iOS: Native Share Extension (this module)
- Android: Native share intents handled via MainActivity intent filters in AndroidManifest.xml
- Web: Not applicable (browser share APIs use different mechanisms)
## Implementation Details
### Image Processing
When images are shared:
1. Images are loaded from the extension's temporary directory or as UIImage objects
2. Images are converted to JPEG format at maximum quality
3. Dimensions are extracted from the UIImage
4. Files are saved to the App Group container with unique names
5. URIs are formatted as `<file-url>|<width>|<height>`
### Video Processing
When videos are shared:
1. Videos are copied from the source URL to the App Group container
2. AVURLAsset is used to extract video track dimensions
3. Track dimensions are adjusted for video rotation using preferredTransform
4. URI is formatted as `<file-url>|<width>|<height>`
### Security
- External URLs in image URIs are filtered out in the main app to prevent potential security issues
- Only file:// URLs from the App Group container are accepted
- URI format is validated with a regex pattern before processing
## Development
This module is built as part of the main Xcode project. The extension target is included in the iOS build configuration.
To modify the extension:
1. Open the Xcode project in `/ios`
2. Navigate to the Share-with-Bluesky target
3. Edit `ShareViewController.swift` for logic changes
4. Edit `Info.plist` for configuration changes
5. Rebuild the iOS app
## Limitations
- Images: Maximum of 4 images per share (limited in main app handler)
- Videos: Only 1 video per share
- Mixed media: Cannot share images and videos together
- File size: No explicit limits, but large files may cause issues
- Formats: Only supports common image/video formats listed in constants
+248
View File
@@ -0,0 +1,248 @@
# Bottom Sheet Expo Module
A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android).
## Overview
This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior.
Key features:
- Native bottom sheet presentation on iOS and Android
- Automatic content height detection (no JS bridge round-trip)
- Configurable snap points (hidden, partial, full)
- Drag-to-dismiss with prevention controls
- Portal-based rendering for proper z-index layering
- Edge-to-edge support on modern Android versions
- iOS 26+ zoom transition support
## Platform Support
- **iOS**: Uses `UISheetPresentationController` (iOS 15+)
- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior`
- **Web**: Not supported (throws error)
## Architecture
### TypeScript Layer
The module exposes a React component that handles rendering and state management:
- **BottomSheet.tsx** (Native): Main component wrapping the native view
- **BottomSheet.web.tsx** (Web): Stub that throws an error
- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration
- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content
- **Portal.tsx**: Generic portal implementation for managing component hierarchy
The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`).
### Native Layer
#### iOS Implementation
- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings
- **SheetView.swift**: Main view component that creates and manages `SheetViewController`
- Observes content height via KVO (Key-Value Observing) on bounds
- Manages sheet lifecycle and state transitions
- Implements `UISheetPresentationControllerDelegate` for drag events
- **SheetViewController.swift**: UIViewController subclass with sheet presentation
- Configures detents (snap points) based on content height
- Handles iOS 26+ safe area adjustments for floating sheet style
- Animates detent changes when content resizes
- **SheetManager.swift**: Singleton that tracks all active sheets with weak references
- **Util.swift**: Helper for calculating screen height minus safe area insets
#### Android Implementation
- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality
- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog
- Uses `OnLayoutChangeListener` to observe content height natively
- Configures `BottomSheetBehavior` for drag and snap behavior
- Handles edge-to-edge display across Android versions (API 29-35+)
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
### Content Height Detection
Both platforms detect content height changes natively without JS bridge round-trips:
- **iOS**: KVO observation on the content view's `bounds` property
- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls)
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
## Props
```typescript
interface BottomSheetViewProps {
children: React.ReactNode
// Appearance
cornerRadius?: number
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
// Behavior
preventDismiss?: boolean // Disable swipe-to-dismiss
preventExpansion?: boolean // Lock to initial height (no full-screen)
disableDrag?: boolean // Disable drag handle (Android only)
fullHeight?: boolean // Start at full screen height
// Height constraints
minHeight?: number // Minimum height in dp
maxHeight?: number // Maximum height in dp
// iOS 26+ transition
sourceViewTag?: number // View tag for zoom transition origin
// Events
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
onStateChange?: (event: BottomSheetStateChangeEvent) => void
}
```
## States and Snap Points
### States
- `closed`: Sheet is dismissed
- `closing`: Sheet is animating closed
- `open`: Sheet is fully visible
- `opening`: Sheet is animating open
### Snap Points
- `Hidden` (0): Dismissed
- `Partial` (1): Half-expanded / content height
- `Full` (2): Expanded to screen height
## Usage
### Basic Example
```tsx
import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
// In your app root:
function App() {
return (
<BottomSheetProvider>
<YourApp />
<BottomSheetOutlet />
</BottomSheetProvider>
)
}
// In a component:
function MyComponent() {
const sheetRef = useRef<BottomSheet>(null)
const openSheet = () => {
sheetRef.current?.present()
}
const closeSheet = () => {
sheetRef.current?.dismiss()
}
return (
<>
<Button onPress={openSheet} title="Open Sheet" />
<BottomSheet
ref={sheetRef}
cornerRadius={16}
backgroundColor="white"
onStateChange={(e) => console.log(e.nativeEvent.state)}
>
<View style={{padding: 20}}>
<Text>Sheet content</Text>
<Button onPress={closeSheet} title="Close" />
</View>
</BottomSheet>
</>
)
}
```
### Nested Sheets
The module supports nesting sheets by using `BottomSheetPortalProvider` within sheet content:
```tsx
<BottomSheet ref={outerSheetRef}>
<BottomSheetPortalProvider>
<Button onPress={() => innerSheetRef.current?.present()} />
<BottomSheet ref={innerSheetRef}>
<Text>Inner sheet content</Text>
</BottomSheet>
</BottomSheetPortalProvider>
</BottomSheet>
```
### Dismiss All Sheets
```tsx
import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
BottomSheetNativeComponent.dismissAll()
```
## Key Implementation Details
### iOS Specific
1. **iOS 15 Compatibility**: On iOS 15, custom detents are not available, so the module uses `.medium()` detent and applies extra styling to prevent visual issues.
2. **iOS 26+ Zoom Transitions**: When `sourceViewTag` is provided on iOS 26+, the sheet zooms from the specified view.
3. **Detent Selection**: The module automatically chooses between custom detents, `.medium()`, and `.large()` based on content height and screen size.
### Android Specific
1. **Edge-to-Edge**: The module handles edge-to-edge display correctly across API levels:
- API 35+: Mandatory edge-to-edge
- API 30-34: Uses `currentWindowMetrics`
- API <30: Uses deprecated `getRealSize()`
2. **Status/Nav Bar Appearance**: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
3. **Drag Handling**: On full-height sheets with `preventDismiss`, dragging is disabled to prevent accidental dismissal (since there's no half-expanded snap point to land on).
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
- **disableDrag**: Android-only prop (iOS drag behavior is controlled via `preventDismiss` + `preventExpansion`)
- **sourceViewTag**: iOS 26+ only (ignored on Android)
## Files Reference
### TypeScript
- `index.ts` - Public API exports
- `src/BottomSheet.types.ts` - TypeScript type definitions
- `src/BottomSheet.tsx` - Native component (re-export)
- `src/BottomSheet.web.tsx` - Web stub
- `src/BottomSheetNativeComponent.tsx` - Native wrapper with portal integration
- `src/BottomSheetNativeComponent.web.tsx` - Web stub for native component
- `src/BottomSheetPortal.tsx` - Portal context and providers
- `src/lib/Portal.tsx` - Generic portal implementation
### iOS
- `ios/BottomSheetModule.swift` - Module definition
- `ios/SheetView.swift` - Main view implementation
- `ios/SheetViewController.swift` - View controller for sheet presentation
- `ios/SheetManager.swift` - Singleton for tracking active sheets
- `ios/Util.swift` - Screen height utility
### Android
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt` - Module definition
- `android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt` - Main view implementation
- `android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt` - Dialog root view group
- `android/src/main/java/expo/modules/bottomsheet/SheetManager.kt` - Sheet tracking singleton
### Configuration
- `expo-module.config.json` - Expo module configuration
@@ -1,4 +1,4 @@
import * as React from 'react'
import {Component, createRef} from 'react'
import {
Dimensions,
type LayoutChangeEvent,
@@ -39,14 +39,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
ref = createRef<any>()
static contextType = PortalContext
@@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component<
function BottomSheetNativeComponentInner({
children,
backgroundColor,
maxHeight,
onLayout,
onStateChange,
nativeViewRef,
@@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
flex: 1,
backgroundColor,
},
maxHeight != null && {maxHeight},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
},
extraStyles,
]}>
<View onLayout={onLayout}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
@@ -0,0 +1,162 @@
# expo-background-notification-handler
A custom Expo module for managing shared notification preferences and handling background notifications in the Bluesky Social app. This module enables communication between the main app and notification service extensions through shared storage.
## Purpose
This module solves a critical problem in native notification handling: notification service extensions run in a separate process from the main app and cannot directly access React Native state or APIs. The module provides a bridge by storing notification preferences in shared storage that both the main app and notification service extension can access.
The primary use case is storing user preferences (like notification sound settings) while the app is foregrounded or backgrounded, minimizing the need for background fetches when processing notifications.
## Platform Support
- **iOS**: Full support via UserDefaults with App Groups
- **Android**: Full support via SharedPreferences
- **Web**: Stub implementation (no-op)
## Architecture
### iOS Implementation
Uses iOS App Groups (`group.app.bsky`) to share UserDefaults between the main app and the notification service extension. This allows the notification service extension to read preferences set by the main app without launching the app.
**Key Files:**
- `ios/ExpoBackgroundNotificationHandlerModule.swift` - Native module implementation
- `ios/ExpoBackgroundNotificationHandler.podspec` - CocoaPods specification
### Android Implementation
Uses SharedPreferences with Firebase Cloud Messaging (FCM) to handle background notifications. The module tracks app foreground/background state and conditionally processes notifications based on whether the app is foregrounded.
**Key Files:**
- `android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt` - Expo module definition
- `android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt` - SharedPreferences wrapper
- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt` - Notification processing logic
- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt` - Interface for showing notifications
- `android/build.gradle` - Build configuration
### TypeScript/React API
**Key Files:**
- `index.ts` - Module entry point
- `src/ExpoBackgroundNotificationHandlerModule.ts` - Native module binding (iOS/Android)
- `src/ExpoBackgroundNotificationHandlerModule.web.ts` - Web stub
- `src/ExpoBackgroundNotificationHandler.types.ts` - TypeScript type definitions
- `src/BackgroundNotificationHandlerProvider.tsx` - React Context provider for preferences
## Stored Preferences
The module manages the following notification preferences:
```typescript
{
playSoundChat: boolean, // Currently exposed to TypeScript
playSoundFollow: boolean, // Native only (not yet exposed)
playSoundLike: boolean, // Native only (not yet exposed)
playSoundMention: boolean, // Native only (not yet exposed)
playSoundQuote: boolean, // Native only (not yet exposed)
playSoundReply: boolean, // Native only (not yet exposed)
playSoundRepost: boolean, // Native only (not yet exposed)
mutedThreads: [String: [String]], // iOS only
badgeCount: number // iOS only
}
```
Default values are initialized when the module is created, with most sound preferences defaulting to `false` except `playSoundChat` which defaults to `true`.
## API
### Core Methods
```typescript
// Get all preferences
getAllPrefsAsync(): Promise<BackgroundNotificationHandlerPreferences>
// Get individual values
getBoolAsync(forKey: string): Promise<boolean>
getStringAsync(forKey: string): Promise<string>
getStringArrayAsync(forKey: string): Promise<string[]>
// Set individual values
setBoolAsync(forKey: string, value: boolean): Promise<void>
setStringAsync(forKey: string, value: string): Promise<void>
setStringArrayAsync(forKey: string, value: string[]): Promise<void>
// Array manipulation
addToStringArrayAsync(forKey: string, value: string): Promise<void>
removeFromStringArrayAsync(forKey: string, value: string): Promise<void>
addManyToStringArrayAsync(forKey: string, value: string[]): Promise<void>
removeManyFromStringArrayAsync(forKey: string, value: string[]): Promise<void>
// Badge count (iOS only)
setBadgeCountAsync(count: number): Promise<void>
```
### React Context API
The module provides a React Context provider for managing preferences in the app:
```typescript
import {
BackgroundNotificationPreferencesProvider,
useBackgroundNotificationPreferences,
} from 'expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
function App() {
return (
<BackgroundNotificationPreferencesProvider>
<YourApp />
</BackgroundNotificationPreferencesProvider>
)
}
function SettingsScreen() {
const {preferences, setPref} = useBackgroundNotificationPreferences()
return (
<Toggle
value={preferences.playSoundChat}
onValueChange={(value) => setPref('playSoundChat', value)}
/>
)
}
```
## Android Notification Handling
The Android implementation includes logic for processing notifications while the app is backgrounded:
- **Chat messages**: Applies custom notification channels based on `playSoundChat` preference
- Sound enabled: Uses `chat-messages` channel (or `dm.mp3` sound on older Android)
- Sound disabled: Uses `chat-messages-muted` channel
- **Other notification types**: On Android Oreo+ (API 26+), assigns notifications to channels based on reason:
- Supported reasons: `like`, `repost`, `follow`, `mention`, `reply`, `quote`, `like-via-repost`, `repost-via-repost`, `subscribed-post`
- Each reason maps to its corresponding notification channel
When the app is foregrounded, the module defers to `expo-notifications` for notification handling.
## Configuration
### iOS
Requires App Group entitlement configured in Xcode:
- App Group ID: `group.app.bsky`
### Android
Requires Firebase Cloud Messaging (FCM) integration:
- Dependency: `com.google.firebase:firebase-messaging-ktx:24.0.0`
- SharedPreferences name: `xyz.blueskyweb.app`
## Usage in the App
The module is used to:
1. Store notification preferences that need to be accessed by notification service extensions
2. Track app foreground/background state on Android
3. Process and mutate notification payloads based on user preferences before display
4. Manage notification badge counts on iOS
5. Handle thread muting and other notification filtering logic
By keeping preferences in shared storage, the notification service extension can make intelligent decisions about notification presentation without waking up the React Native runtime or making network requests.
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
return
}
if (remoteMessage.data["reason"] == "chat-message") {
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
mutateWithChatMessage(remoteMessage)
} else {
mutateWithOtherReason(remoteMessage)
+167
View File
@@ -0,0 +1,167 @@
# expo-bluesky-gif-view
An Expo module for displaying animated GIFs and WebP images with optimized performance and playback controls.
## Overview
This module provides a custom view component for rendering animated GIFs with support for:
- Autoplay control
- Placeholder images while loading
- Programmatic playback control (play/pause/toggle)
- Image prefetching
- Efficient memory management
- Player state change events
## Platform Support
- iOS (13.4+)
- Android (API 21+)
- Web
## Architecture
The module uses native platform libraries for optimal GIF rendering performance:
### iOS Implementation
- **Library**: SDWebImage with SDWebImageWebPCoder
- **Key Files**:
- `ios/GifView.swift` - Main view implementation using `SDAnimatedImageView`
- `ios/ExpoBlueskyGifViewModule.swift` - Module definition and prop bindings
- `ios/Util.swift` - Cache configuration utilities
**Approach**: Uses `SDAnimatedImageView` for hardware-accelerated GIF rendering. Images are cached to disk only (not memory) to avoid performance issues with `SDAnimatedImage` when loaded from memory. The view automatically cancels pending requests when scrolled off-screen and resumes loading when visible.
### Android Implementation
- **Library**: Glide
- **Key Files**:
- `android/src/main/java/expo/modules/blueskygifview/GifView.kt` - Main view implementation
- `android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt` - Module definition
- `android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt` - Custom ImageView with playback control
**Approach**: Uses Glide's disk cache strategy for loading animated GIFs. Placeholders are loaded with `skipMemoryCache(true)` to avoid cache bloat. The custom `AppCompatImageViewExtended` detects when animations are loaded via `onDraw` and manages the `Animatable` drawable lifecycle.
### Web Implementation
- **Library**: Native HTML5 `<video>` element
- **Key File**: `src/GifView.web.tsx`
**Approach**: Uses a looping, muted video element to display GIFs. This provides better performance than image-based approaches on the web. The implementation tracks load state to fire the `onPlayerStateChange` event only once (since `onCanPlay` fires on every loop).
## Usage
```tsx
import {GifView} from 'expo-bluesky-gif-view'
function MyComponent() {
const gifRef = React.useRef<GifView>(null)
return (
<GifView
source="https://example.com/animated.gif"
placeholderSource="https://example.com/thumbnail.jpg"
autoplay={true}
onPlayerStateChange={(event) => {
console.log('Playing:', event.nativeEvent.isPlaying)
console.log('Loaded:', event.nativeEvent.isLoaded)
}}
ref={gifRef}
/>
)
}
```
## API
### Props
- `source?: string` - URL of the animated GIF/WebP
- `placeholderSource?: string` - URL of a static placeholder image to show while loading
- `autoplay?: boolean` - Whether to start playing automatically (default: true)
- `onPlayerStateChange?: (event: GifViewStateChangeEvent) => void` - Callback fired when playback state changes
### Methods
All methods are async and return a Promise:
```tsx
await gifRef.current?.playAsync()
await gifRef.current?.pauseAsync()
await gifRef.current?.toggleAsync()
```
### Static Methods
```tsx
// Prefetch GIFs into the cache (not supported on web)
await GifView.prefetchAsync([
'https://example.com/gif1.gif',
'https://example.com/gif2.gif'
])
```
## Configuration
### iOS Dependencies
The module requires SDWebImage and SDWebImageWebPCoder:
```ruby
# ios/ExpoBlueskyGifView.podspec
s.dependency 'SDWebImage', '~> 5.21.0'
s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
```
### Android Dependencies
The module uses Glide, kept in sync with expo-image version:
```gradle
# android/build.gradle
implementation 'com.github.bumptech.glide:glide:4.13.2'
```
## Key Implementation Details
### Lifecycle Management
- **iOS**: Cancels pending requests in `willMove(toWindow:)` when scrolled off-screen
- **Android**: Pauses playback in `onDetachedFromWindow()`, resumes in `onAttachedToWindow()`
- **Web**: Uses React lifecycle methods to manage video element state
### Cache Strategy
- **iOS**: Disk-only caching to work around `SDAnimatedImage` memory issues
- **Android**: DATA disk cache for main images, skips memory cache for placeholders
- **Web**: Relies on browser cache
### Animation Control
- **iOS**: `SDAnimatedImageView.autoPlayAnimatedImage` is explicitly set to false to prevent automatic animation on viewport entry
- **Android**: Custom `AppCompatImageViewExtended` manages `Animatable` drawable state
- **Web**: Uses HTMLMediaElement play/pause APIs
## Files Overview
```
expo-bluesky-gif-view/
├── index.ts # Module entry point
├── expo-module.config.json # Expo module configuration
├── src/
│ ├── GifView.types.ts # TypeScript type definitions
│ ├── GifView.tsx # Native implementation (iOS/Android)
│ └── GifView.web.tsx # Web implementation
├── ios/
│ ├── ExpoBlueskyGifView.podspec # CocoaPods spec
│ ├── ExpoBlueskyGifViewModule.swift # Module and prop definitions
│ ├── GifView.swift # iOS view implementation
│ └── Util.swift # Cache configuration
└── android/
├── build.gradle # Gradle build configuration
└── src/main/java/expo/modules/blueskygifview/
├── ExpoBlueskyGifViewModule.kt # Module and prop definitions
├── GifView.kt # Android view implementation
└── AppCompatImageViewExtended.kt # Custom ImageView for playback
```
+231
View File
@@ -0,0 +1,231 @@
# expo-bluesky-swiss-army
A collection of native utilities for the Bluesky Social app. This Expo module provides platform-specific functionality that is not available through standard React Native APIs.
## Overview
This module consolidates several native features into a single Expo module:
- **PlatformInfo**: Platform-specific accessibility and audio session management
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
- **VisibilityView**: A native view component that tracks which view is currently visible on screen
## Modules
### PlatformInfo
Provides platform-specific information and audio session control.
**Functions:**
- `getIsReducedMotionEnabled(): boolean` - Returns whether the user has enabled reduced motion in system settings. Works on all platforms (iOS uses UIAccessibility, Android checks transition animation scale, Web checks CSS media query).
- `setAudioActive(active: boolean): void` - iOS only. Controls whether the app's audio session is active. When deactivated with `false`, it notifies other apps to resume their audio playback.
- `setAudioCategory(category: AudioCategory): void` - iOS only. Sets the AVAudioSession category. Use `AudioCategory.Playback` for video/music playback and `AudioCategory.Ambient` for audio that mixes with other apps.
**Platform Support:**
- iOS: Full support for all functions
- Android: `getIsReducedMotionEnabled()` only
- Web: `getIsReducedMotionEnabled()` only
### Referrer
Tracks how users arrive at the app from external sources.
**Functions:**
- `getReferrerInfo(): ReferrerInfo | null` - Returns information about the source that launched the app. Returns `{referrer: string, hostname: string}` or `null`.
- **iOS**: Reads from SharedPrefs (set by app extensions or deep link handlers)
- **Android**: Extracts referrer from Intent extras or activity referrer
- **Web**: Parses `document.referrer` (excludes bsky.app domain)
- `getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo>` - Android only. Retrieves Google Play install referrer information including install timestamp and click timestamp. Uses the Google Play Install Referrer API.
**Platform Support:**
- iOS: `getReferrerInfo()` only (reads from SharedPrefs)
- Android: Both functions
- Web: `getReferrerInfo()` only
### SharedPrefs
Native key-value storage that persists across app restarts. Uses iOS App Groups (`group.app.bsky`) for sharing data with extensions, and Android SharedPreferences.
**Functions:**
- `setValue(key: string, value: string | number | boolean | null | undefined): void` - Store a value
- `removeValue(key: string): void` - Remove a value
- `getString(key: string): string | undefined` - Get a string value
- `getNumber(key: string): number | undefined` - Get a number value
- `getBool(key: string): boolean | undefined` - Get a boolean value
- `addToSet(key: string, value: string): void` - Add a value to a set
- `removeFromSet(key: string, value: string): void` - Remove a value from a set
- `setContains(key: string, value: string): boolean` - Check if a set contains a value
**Default Values (Android only):**
The Android implementation initializes certain keys with default values on first access:
- `playSoundChat`: true
- `playSoundFollow`: false
- `playSoundLike`: false
- `playSoundMention`: false
- `playSoundQuote`: false
- `playSoundReply`: false
- `playSoundRepost`: false
- `badgeCount`: 0
**Platform Support:**
- iOS: Full support (uses UserDefaults with App Group)
- Android: Full support (uses SharedPreferences)
- Web: Not implemented
**Implementation Notes:**
- iOS uses App Group suite `group.app.bsky` to share preferences with app extensions
- Android stores preferences in `xyz.blueskyweb.app`
- Both platforms work around a bug where `JavaScriptValue.isString()` can cause crashes, so there's a separate `setString` function internally
### VisibilityView
A React Native view component that detects which view is currently "active" based on visibility and position on screen. Only one view can be active at a time across the entire app.
**Component:**
```tsx
<VisibilityView
enabled={boolean}
onChangeStatus={(isActive: boolean) => void}
>
{children}
</VisibilityView>
```
**Props:**
- `enabled: boolean` - Whether this view participates in visibility tracking
- `onChangeStatus: (isActive: boolean) => void` - Callback fired when the view becomes active or inactive
- `children: React.ReactNode` - Child components
**Functions:**
- `updateActiveViewAsync(): Promise<void>` - Manually trigger recalculation of the active view
**How It Works:**
The module maintains a global registry of all VisibilityView instances. When views are added/removed or when explicitly updated, it calculates which view is "most visible":
1. A view must be at least 50% visible on screen
2. If multiple views meet this threshold, the one closest to the top of the screen wins (specifically, the one with the lowest Y position, but must be at least 150px from the top)
3. Only one view can be active at a time - when a new view becomes active, the previous one is deactivated
This is useful for features like video autoplay, where you want to know which video is currently the "primary" one the user is viewing.
**Platform Support:**
- iOS: Full support using UIView position tracking
- Android: Full support using View position tracking
- Web: Passthrough component (renders children without tracking)
## Architecture
### TypeScript Layer
The module uses platform-specific file extensions to provide appropriate implementations:
- `index.ts` - Throws NotImplementedError (base/fallback)
- `index.native.ts` - Calls native modules via Expo Modules Core
- `index.web.ts` - Web-specific implementations or stubs
- `index.ios.ts` / `index.android.ts` - Platform-specific implementations when behavior differs
### Native Layer
**iOS:**
- Swift implementation using Expo Modules Core
- Files organized by feature in subdirectories (PlatformInfo/, Referrer/, SharedPrefs/, Visibility/)
- Uses standard iOS APIs: UIAccessibility, AVAudioSession, UserDefaults, UIView
**Android:**
- Kotlin implementation using Expo Modules Core
- Package structure: `expo.modules.blueskyswissarmy.[feature]`
- Uses standard Android APIs: Settings.Global, InstallReferrerClient, SharedPreferences, View
## Key Files
### TypeScript
- `index.ts` - Main module exports
- `src/NotImplemented.ts` - Error thrown when functionality is not available on current platform
- `src/[Feature]/types.ts` - TypeScript type definitions for each feature
- `src/[Feature]/index.*.ts` - Platform-specific implementations
### iOS
- `ios/ExpoBlueskySwissArmy.podspec` - CocoaPods specification
- `ios/[Feature]/Expo*Module.swift` - Expo module definitions
- `ios/SharedPrefs/SharedPrefs.swift` - Shared preference manager (usable from other native code)
- `ios/Visibility/VisibilityViewManager.swift` - Global view tracking manager
### Android
- `android/build.gradle` - Gradle build configuration (includes installreferrer dependency)
- `android/src/main/java/expo/modules/blueskyswissarmy/[feature]/Expo*Module.kt` - Expo module definitions
- `android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt` - Shared preference manager
- `android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt` - Global view tracking manager
## Configuration
### Expo Module Config
The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android.
### iOS
Requires iOS 13.4 or later. Uses the App Group `group.app.bsky` for SharedPrefs - ensure this is configured in your app's entitlements.
### Android
- Minimum SDK: 21
- Target SDK: 34
- Requires `com.android.installreferrer:installreferrer:2.2` dependency for Google Play referrer tracking
## Usage Example
```typescript
import {
PlatformInfo,
AudioCategory,
Referrer,
SharedPrefs,
VisibilityView
} from 'expo-bluesky-swiss-army'
// Check for reduced motion
const isReducedMotion = PlatformInfo.getIsReducedMotionEnabled()
// Set audio category for video playback (iOS)
PlatformInfo.setAudioCategory(AudioCategory.Playback)
PlatformInfo.setAudioActive(true)
// Check how user arrived at the app
const referrer = Referrer.getReferrerInfo()
if (referrer) {
console.log('User came from:', referrer.hostname)
}
// Store a preference
SharedPrefs.setValue('lastOpenedAt', Date.now())
SharedPrefs.setValue('hasSeenOnboarding', true)
// Track visible view
<VisibilityView
enabled={true}
onChangeStatus={(isActive) => {
if (isActive) {
// This view is now the primary visible view
video.play()
} else {
video.pause()
}
}}
>
<VideoPlayer />
</VisibilityView>
```
## Version
Current version: 0.6.0
+113 -1
View File
@@ -1,3 +1,115 @@
# expo-emoji-picker
Based on [react-native-emoji-popup](https://github.com/okwasniewski/react-native-emoji-popup) and [expo-emoji-picker](https://github.com/alanjhughes/expo-emoji-picker)
A native emoji picker module for React Native applications built with Expo. This module provides platform-specific emoji selection interfaces using native system components.
Based on [react-native-emoji-popup](https://github.com/okwasniewski/react-native-emoji-popup) and [expo-emoji-picker](https://github.com/alanjhughes/expo-emoji-picker).
## What It Does
The module exposes a React component that presents native emoji picker UI on iOS and Android. When a user selects an emoji, it fires a callback with the selected emoji string.
## Platform Support
- **iOS**: Uses [MCEmojiPicker](https://github.com/izyumkin/MCEmojiPicker) presented as a modal picker
- **Android**: Uses the system `androidx.emoji2.emojipicker.EmojiPickerView` component
- **Web**: Not supported (native platforms only)
## How It Works
### Architecture
The module follows Expo's module architecture with three layers:
1. **JavaScript/TypeScript Layer** (`src/`): React components and type definitions
2. **Native iOS Layer** (`ios/`): Swift implementation using MCEmojiPicker
3. **Native Android Layer** (`android/`): Kotlin implementation using AndroidX emoji picker
### iOS Implementation
On iOS, the module creates an invisible tap target view. When tapped, it presents MCEmojiPicker as a modal view controller:
- `EmojiPickerView.swift`: Custom view that handles tap gestures and presents the picker
- `EmojiPickerModule.swift`: Module definition that registers the view with Expo
- Uses MCEmojiPicker dependency for the native picker UI
The picker is presented from the current React view controller and returns the selected emoji via an event dispatcher.
### Android Implementation
On Android, the module embeds the AndroidX EmojiPickerView directly as a full-screen component:
- `EmojiPickerModuleView.kt`: Wraps the system EmojiPickerView in an ExpoView
- `EmojiPickerModule.kt`: Module definition that registers the view with Expo
- Handles configuration changes (dark mode, orientation) by recreating the view
The AndroidX emoji picker provides a grid-based interface with category tabs and search.
### Platform-Specific React Components
The module uses platform-specific file extensions for different behaviors:
- `EmojiPicker.tsx` (iOS): Renders an invisible tap target that accepts children
- `EmojiPicker.android.tsx` (Android): Renders the full emoji picker view with flex: 1 layout
Both components normalize the native event structure to provide a consistent `onEmojiSelected` callback.
## Key Files
### Configuration
- `expo-module.config.json`: Defines the module name and native class mappings for iOS and Android
### TypeScript/React
- `index.ts`: Public exports for the module
- `src/EmojiPickerModule.ts`: Native module registration
- `src/EmojiPickerModule.types.ts`: TypeScript type definitions
- `src/EmojiPickerView.tsx`: Base native view component
- `src/EmojiPicker.tsx`: iOS-specific implementation
- `src/EmojiPicker.android.tsx`: Android-specific implementation
### iOS (Swift)
- `ios/EmojiPickerModule.swift`: Module definition (11 lines)
- `ios/EmojiPickerView.swift`: View implementation with tap handling and picker presentation
- `ios/EmojiPickerModule.podspec`: CocoaPods specification with MCEmojiPicker dependency
### Android (Kotlin)
- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModule.kt`: Module definition
- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModuleView.kt`: View implementation
- `android/build.gradle`: Gradle configuration with androidx.emoji2:emoji2-emojipicker dependency
## Usage
```tsx
import { EmojiPicker } from 'expo-emoji-picker'
function MyComponent() {
const handleEmojiSelected = (emoji: string) => {
console.log('Selected emoji:', emoji)
}
return (
<EmojiPicker onEmojiSelected={handleEmojiSelected}>
{/* On iOS, children render as the tap target */}
{/* On Android, children are ignored - picker is shown directly */}
</EmojiPicker>
)
}
```
## Dependencies
### iOS
- ExpoModulesCore
- MCEmojiPicker (external CocoaPods dependency)
- Minimum iOS version: 15.1
### Android
- expo-modules-core
- androidx.emoji2:emoji2-emojipicker:1.5.0
- Minimum SDK: 21
- Target SDK: 34
## Configuration
No additional configuration is required. The module is automatically linked through Expo's autolinking system when the app is built.
The module definition in `expo-module.config.json` specifies the native class names for each platform, which Expo uses to register the module at runtime.
+118 -5
View File
@@ -1,8 +1,121 @@
# Expo Receive Android Intents
This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
platforms.
An Expo module that handles incoming Android intents for sharing text, images, and videos into the Bluesky app.
No installation is required. Gradle will automatically add this module on build.
## What It Does
This module intercepts Android share intents (when a user shares content from another app to Bluesky) and converts them into deep links that the app can handle. It supports:
- **Text sharing** - Share plain text to compose a post
- **Image sharing** - Share single or multiple images (up to 4) to attach to a post
- **Video sharing** - Share a single video to attach to a post
The module operates entirely in native Android code and requires no JavaScript API calls. It automatically registers itself with Expo's module system and handles intents when the app is launched or receives new intents.
## Platform Support
- **Android**: Fully supported
- **iOS**: No-op (iOS handles share intents differently)
- **Web**: No-op
## How It Works
### Architecture
The module uses Expo's module lifecycle hooks to intercept Android intents at two key moments:
1. **OnCreate** - When the app is first launched from an intent
2. **OnNewIntent** - When the app receives a new intent while already running
### Intent Processing Flow
1. **Intent Reception**: Android sends an `ACTION_SEND` or `ACTION_SEND_MULTIPLE` intent
2. **Type Detection**: Module determines content type (text, image, or video)
3. **Content Processing**:
- **Text**: URL-encodes the text
- **Images**: Saves to app cache, extracts dimensions (limited to 4 images max)
- **Video**: Copies to app cache with extension detection, extracts dimensions
4. **Deep Link Generation**: Creates a `bluesky://intent/compose` URL with encoded parameters
5. **App Launch**: Starts a new activity with the deep link, which is handled by `useIntentHandler`
### Deep Link Format
The module generates deep links in the following formats:
```
# Text only
bluesky://intent/compose?text=<encoded-text>
# Images (single or multiple)
bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>&text=<encoded-text>
# Video (single only)
bluesky://intent/compose?videoUri=<uri>|<width>|<height>&text=<encoded-text>
```
All URIs use the `file://` scheme pointing to files in the app's cache directory. Dimensions are included to avoid expensive measurement operations in JavaScript.
### Security Considerations
- Images and videos are copied to the app's private cache directory before being passed to the app
- The JavaScript handler (`useIntentHandler.ts`) validates image URIs with a regex to prevent external URLs
- Image URIs containing `http://` or `https://` are filtered out
- Multiple image sharing is limited to 4 images maximum
## Key Files
### Module Configuration
- **expo-module.config.json** - Declares the module and registers it with Expo (Android-only)
### Native Implementation
- **ExpoReceiveAndroidIntentsModule.kt** - Main module class with intent handling logic
- `handleIntent()` - Routes intents based on type
- `handleTextIntent()` - Processes text sharing
- `handleAttachmentIntent()` - Processes single image/video
- `handleAttachmentsIntent()` - Processes multiple images
- `getImageInfo()` - Saves images to cache and extracts dimensions
- `getVideoInfo()` - Extracts video dimensions using MediaMetadataRetriever
- **android/build.gradle** - Gradle build configuration
- Version: 0.4.1
- Requires: Kotlin, expo-modules-core
- Compile SDK: 33, Min SDK: 21, Target SDK: 34
- **android/src/main/AndroidManifest.xml** - Empty manifest (intent filters configured in main app)
### JavaScript Integration
The deep links generated by this module are handled by:
- **src/lib/hooks/useIntentHandler.ts** - `useComposeIntent()` parses the deep link parameters and opens the composer with pre-populated content
## Installation
No manual installation is required. Gradle automatically includes this module during the Android build process. The module is auto-linked through Expo's module system.
## Configuration
Intent filters must be configured in the main app's `AndroidManifest.xml` to declare which MIME types the app accepts. The module itself has an empty manifest.
## Implementation Notes
### Android Version Compatibility
The module uses version-specific APIs for Android 13+ (API 33):
- `getParcelableExtra()` with type parameter on Android 13+
- Legacy `getParcelableExtra()` on older versions
### File Handling
- Temporary files are created using `File.createTempFile()` in the app's cache directory
- Image files use `.jpeg` extension and are compressed at 100% quality
- Video files preserve their original extension, defaulting to `.mp4` if none is detected
### Limitations
- Video sharing only supports a single video
- Multiple video sharing is not implemented
- Images are always converted to JPEG format
- Maximum of 4 images can be shared at once
+116
View File
@@ -0,0 +1,116 @@
# expo-scroll-forwarder
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
## What It Does
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
Key behaviors:
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
- Implements physics-based deceleration animations that match native scroll behavior
- Supports pull-to-refresh interactions with haptic feedback
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
- Provides rubber-band damping when scrolling past content bounds
## Architecture
The module consists of three main parts:
### 1. Native iOS Implementation (Swift)
**ExpoScrollForwarderView.swift** - The core native view component that:
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
- Finds and references the target RCTScrollView using its React Native tag
- Implements custom scroll physics including velocity-based decay animation
- Manages gesture recognizer delegation to prevent conflicts with system gestures
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
- Registers the view component with Expo
- Exposes the `scrollViewTag` prop to specify which scroll view to control
### 2. TypeScript Interface
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
- `scrollViewTag`: The React Native tag of the scroll view to control
- `children`: The content to render (typically a header component)
### 3. Module Configuration
**expo-module.config.json** - Declares iOS-only platform support
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
## Usage
```tsx
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
function ProfileScreen() {
const scrollViewTag = useRef(null)
return (
<View>
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
<ProfileHeader />
</ExpoScrollForwarderView>
<ScrollView ref={scrollViewTag}>
{/* Scrollable content */}
</ScrollView>
</View>
)
}
```
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
## Platform Support
- **iOS**: Full native implementation with custom scroll physics
- **Android**: No-op wrapper (renders children without scroll forwarding)
- **Web**: No-op wrapper (renders children without scroll forwarding)
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
## Key Implementation Details
### Gesture Recognition
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
### Scroll Physics
- Implements custom decay animation at 120fps using a Timer
- Velocity decay factor: 0.9875 per frame
- Velocity clamped to +/- 5000 points/second
- Rubber-band damping: offsets below 0 are reduced by 55%
- Animation stops when velocity drops below 5 points/second
### Pull-to-Refresh
- Triggers at -130pt scroll offset
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
### Scroll View Management
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
- Properly cleans up gesture recognizers when switching between scroll views
- Maintains references to both the scroll view and its refresh control
## Files Overview
| File | Purpose |
|------|---------|
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
| `index.ts` | Module entry point |
| `expo-module.config.json` | Expo module configuration |
+13 -9
View File
@@ -1,9 +1,9 @@
{
"name": "bsky.app",
"version": "1.121.0",
"version": "1.122.0",
"private": true,
"engines": {
"node": ">=20"
"node": ">=24.15.0"
},
"packageManager": "yarn@1.22.22",
"expo": {
@@ -51,7 +51,7 @@
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"typecheck": "tsgo --project ./tsconfig.check.json",
"e2e:mock-server": "cd dev-env && yarn start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
@@ -81,16 +81,19 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.9",
"@atproto/api": "^0.19.13",
"@atproto/syntax": "0.5.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/alf": "^0.1.9",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.3",
"@bsky.app/tapper": "^0.5.1",
"@bsky.app/sift": "^0.3.4",
"@bsky.app/tapper": "^0.5.3",
"@bsky.app/video": "0.3.4",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -108,7 +111,6 @@
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
@@ -136,7 +138,7 @@
"@tiptap/suggestion": "^2.9.1",
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^20.14.3",
"@types/node": "^24.12.2",
"array.prototype.findlast": "^1.2.3",
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
@@ -233,6 +235,7 @@
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
"setimmediate": "^1.0.5",
"slugify": "^1.6.9",
"sonner": "^2.0.7",
"sonner-native": "^0.21.0",
"tippy.js": "^6.3.7",
@@ -261,6 +264,7 @@
"@types/psl": "^1.1.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
-136
View File
@@ -1,136 +0,0 @@
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
index b988d3f..7743421 100644
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
@@ -36,6 +36,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ consumerProguardFiles 'proguard-rules.pro'
}
lintOptions {
abortOnError false
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
new file mode 100644
index 0000000..3b5b864
--- /dev/null
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
+29
View File
@@ -0,0 +1,29 @@
diff --git a/node_modules/expo-image-picker/ios/MediaHandler.swift b/node_modules/expo-image-picker/ios/MediaHandler.swift
index 6e4fbe1..e334abb 100644
--- a/node_modules/expo-image-picker/ios/MediaHandler.swift
+++ b/node_modules/expo-image-picker/ios/MediaHandler.swift
@@ -310,10 +310,12 @@ internal struct MediaHandler {
let fileExtension = getFileExtension(from: originalFilename)
let destinationUrl = try generateUrl(withFileExtension: fileExtension)
+ let resourceOptions = PHAssetResourceRequestOptions()
+ resourceOptions.isNetworkAccessAllowed = true
try await PHAssetResourceManager.default().writeData(
for: resource,
toFile: destinationUrl,
- options: nil
+ options: resourceOptions
)
let mimeType = getMimeType(from: destinationUrl.pathExtension)
@@ -389,7 +391,9 @@ internal struct MediaHandler {
// Stream the resource into our cache directory. This API is asynchronous but doesn't require
// a temporary file like `loadFileRepresentation`.
- try await PHAssetResourceManager.default().writeData(for: resource, toFile: destinationUrl, options: nil)
+ let resourceOptions = PHAssetResourceRequestOptions()
+ resourceOptions.isNetworkAccessAllowed = true
+ try await PHAssetResourceManager.default().writeData(for: resource, toFile: destinationUrl, options: resourceOptions)
// Build and return the result using the helper.
let mimeType = getMimeType(from: destinationUrl.pathExtension)
+136
View File
@@ -0,0 +1,136 @@
diff --git a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
index 2164aec4ec1d..d216db6d2927 100644
--- a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
+++ b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView {
var attachmentRanges: [NSRange] = []
var mediaPayloads: [MediaPayload] = []
+ // Only track ranges for attachments we successfully extract a real payload
+ // from. Attachments without a payload (e.g. iOS dictation placeholders)
+ // are left alone — sanitizing them would delete characters the system
+ // manages itself, and emitting "unsupported" would raise a spurious error.
attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
guard let attachment = value as? NSTextAttachment else {
return
}
- attachmentRanges.append(range)
-
if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) {
+ attachmentRanges.append(range)
mediaPayloads.append(payload)
}
}
@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView {
return
}
- attachmentRanges.append(range)
-
if let payload = self.extractMediaPayload(from: adaptiveGlyph) {
+ attachmentRanges.append(range)
mediaPayloads.append(payload)
}
}
@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView {
attachmentRanges = uniqueRanges(attachmentRanges)
- guard !attachmentRanges.isEmpty else {
- return
- }
-
- sanitizeAttachments(in: textView, ranges: attachmentRanges)
-
guard !mediaPayloads.isEmpty else {
- handleUnsupportedPaste()
return
}
+ sanitizeAttachments(in: textView, ranges: attachmentRanges)
+
emitImagesAsync(for: mediaPayloads)
}
@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView {
}
private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? {
+ // Only accept attachments that carry real image payloads. We intentionally
+ // do not fall back to `image(forBounds:)` or rendering the text view's
+ // hierarchy, because system-inserted attachments (e.g. the iOS dictation
+ // placeholder) draw themselves via those paths and would cause us to
+ // emit a screenshot of the composer as a "pasted image".
if let fileWrapperData = attachment.fileWrapper?.regularFileContents,
let payload = extractMediaPayload(fromData: fileWrapperData) {
return payload
@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView {
return .image(image)
}
- let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0
- ? attachment.bounds
- : CGRect(origin: .zero, size: CGSize(width: 128, height: 128))
-
- if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location),
- image.size.width > 0,
- image.size.height > 0 {
- return .image(image)
- }
-
- if let renderedImage = renderTextAttachment(in: textView, range: range) {
- return .image(renderedImage)
- }
-
return nil
}
@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView {
return .imageData(data)
}
- private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? {
- let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
- var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
-
- rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x
- rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y
- rect = rect.integral
-
- guard rect.width > 1, rect.height > 1 else {
- return nil
- }
-
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = textView.window?.screen.scale ?? UIScreen.main.scale
- format.opaque = false
-
- let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in
- let drawRect = CGRect(
- origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y),
- size: textView.bounds.size
- )
-
- if textView.window != nil {
- textView.drawHierarchy(in: drawRect, afterScreenUpdates: false)
- } else {
- guard let context = UIGraphicsGetCurrentContext() else {
- return
- }
-
- context.translateBy(x: -rect.origin.x, y: -rect.origin.y)
- textView.layer.render(in: context)
- }
- }
-
- guard image.size.width > 0, image.size.height > 0 else {
- return nil
- }
-
- return image
- }
-
@available(iOS 18.0, *)
private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool {
guard let payload = extractMediaPayload(from: adaptiveGlyph) else {
+22
View File
@@ -0,0 +1,22 @@
# Expo Paste Input Patch
`expo-paste-input` observes `UITextView.textDidChangeNotification` and treats any
`NSTextAttachment` in the text view's `attributedText` as a pasted image. When
it can't find a real image payload on an attachment, it falls back to
`image(forBounds:)` and, failing that, to a `drawHierarchy` screenshot of the
text view at the attachment's glyph rect.
iOS Dictation inserts its own `NSTextAttachment` (the shimmer/cursor indicator)
into the text view during dictation. Those attachments don't carry real image
data, so the fallbacks would fire — emitting a zoomed-in screenshot of the
composer as if the user had pasted an image at the end of dictation.
This patch:
- Removes the `image(forBounds:)` and `renderTextAttachment` fallbacks in
`extractMediaPayload` so the library only accepts attachments carrying a real
payload (`fileWrapper`, `contents`, or `image`).
- Only sanitizes (deletes) attachment ranges that produced a payload, and
skips the "unsupported" toast when an attachment has no payload. Unknown
system attachments like the dictation placeholder are left alone rather
than being ripped out from under iOS.
+97
View File
@@ -0,0 +1,97 @@
# push-notification
Sends sample APNS payloads to a booted iOS simulator via `xcrun simctl push`.
Useful for exercising `useNotificationsHandler` (`src/lib/hooks/useNotificationHandler.ts`)
without a real APNS round-trip — foreground display behavior, tap responses,
and notification-driven navigation.
## What this does and doesn't cover
**Covers** — anything in `useNotificationsHandler`:
- `setNotificationHandler` foreground behavior (banner, list, badge, sound flags)
- `addNotificationResponseReceivedListener` tap handling
- Account-switch flow when `recipientDid` differs from the signed-in account
- Navigation routing for each `reason` (post threads, profiles, conversations)
**Does not cover**`BlueskyNSE` (the iOS Notification Service Extension):
- Communication Notification styling for chat messages
- Badge increment via `mutateWithBadge`
- Custom `dm.aiff` sound for chat messages
The simulator does not reliably invoke NSEs for `simctl push` on recent iOS
versions (verified bypassed on iOS 26.4). To test NSE behavior, run on a real
device with a real APNS push, or unit-test `NotificationService.didReceive`
directly in Xcode.
## Setup
1. Boot an iOS simulator and install the app:
```
yarn ios
```
2. Sign in to the account you'll be testing against. The `recipientDid` in
each payload is substituted at send time and must match the signed-in DID,
otherwise:
- Chat notifications trigger the account-switch flow
- Other reasons are silently dropped by the handler
3. Find your DID. Easiest: visit your profile in a web browser and copy it
from the URL, or grep dev logs for `currentAccount`.
## Usage
```
./send.sh <payload-name> [--did <did>] [--device <udid>] [--bundle <id>]
```
Pass the DID once via env var to avoid repeating it:
```
export BLUESKY_TEST_DID=did:plc:yourdidhere
./send.sh like
./send.sh chat-message
./send.sh follow
```
Defaults: `--device booted`, `--bundle xyz.blueskyweb.app`. Run `./send.sh --help`
for the full list of available payloads.
## Foreground vs background
`useNotificationsHandler` behaves differently depending on app state:
- **Foreground** — `setNotificationHandler.handleNotification` decides whether
to show a banner, play a sound, etc. For chat reasons, the banner is
suppressed if `payload.convoId === currentConvoId` (you're already viewing
that conversation).
- **Background or tapped** — `addNotificationResponseReceivedListener` fires
on tap and runs the navigation routing in `notificationToURL`.
To test the response listener, background the app first (`cmd+shift+H` in the
sim), send the push, then tap the banner.
## Available payloads
| Payload | Reason | Navigation target |
|---|---|---|
| `like.apns` | `like` | post thread (from `subject`) |
| `reply.apns` | `reply` | post thread (from `uri`) |
| `follow.apns` | `follow` | sender's profile (from `uri.host`) |
| `chat-message.apns` | `chat-message` | `MessagesConversation` with `convoId` |
| `chat-reaction.apns` | `chat-reaction` | `MessagesConversation` with `convoId` |
The `subject` AT URIs reference fake post rkeys, so the destination screens
will fail to load real content — that's expected. Routing exercises the
navigation path, not the data fetch.
## Adding a new payload
1. Copy an existing `.apns` file in `payloads/` whose shape matches.
2. Set `aps.mutable-content: 1` and a real `aps.alert.{title,body}`.
3. Match the payload shape to `NotificationPayload` in
`src/lib/hooks/useNotificationHandler.ts` for the `reason` you're testing.
4. Use `__RECIPIENT_DID__` as the placeholder for the recipient — `send.sh`
substitutes it at send time. Use it anywhere a DID needs to belong to the
logged-in user (typically `recipientDid`, and post `subject` for likes/replies).
@@ -0,0 +1,15 @@
{
"aps": {
"alert": {
"title": "Ian",
"body": "Beep beep mfer"
},
"mutable-content": 1
},
"reason": "chat-message",
"convoId": "3mkb6y3tjzu2r",
"messageId": "3mkdjhe6sd227",
"recipientDid": "did:plc:3jpt2mvvsumj2r7eqk4gzzjz",
"senderDisplayName": "Ian",
"senderHandle": "iwsmith.bsky.social"
}
@@ -0,0 +1,15 @@
{
"aps": {
"alert": {
"title": "Alice Test",
"body": "reacted to your message"
},
"mutable-content": 1
},
"reason": "chat-reaction",
"convoId": "3kfakeconvo0001",
"messageId": "3kfakemsg0001",
"recipientDid": "__RECIPIENT_DID__",
"senderDisplayName": "Alice Test",
"senderHandle": "alice.test"
}
@@ -0,0 +1,14 @@
{
"aps": {
"alert": {
"title": "alice.test",
"body": "followed you"
},
"mutable-content": 1,
"sound": "default"
},
"reason": "follow",
"uri": "at://did:plc:senderdummy00000000000000/app.bsky.graph.follow/3kfakefollow001",
"subject": "at://__RECIPIENT_DID__",
"recipientDid": "__RECIPIENT_DID__"
}
@@ -0,0 +1,14 @@
{
"aps": {
"alert": {
"title": "alice.test",
"body": "liked your post"
},
"mutable-content": 1,
"sound": "default"
},
"reason": "like",
"uri": "at://did:plc:senderdummy00000000000000/app.bsky.feed.like/3kfakelike0001",
"subject": "at://__RECIPIENT_DID__/app.bsky.feed.post/3kfakepost0001",
"recipientDid": "__RECIPIENT_DID__"
}
@@ -0,0 +1,14 @@
{
"aps": {
"alert": {
"title": "alice.test",
"body": "replied to your post"
},
"mutable-content": 1,
"sound": "default"
},
"reason": "reply",
"uri": "at://did:plc:senderdummy00000000000000/app.bsky.feed.post/3kfakereply0001",
"subject": "at://__RECIPIENT_DID__/app.bsky.feed.post/3kfakepost0001",
"recipientDid": "__RECIPIENT_DID__"
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
# Sends a sample APNS payload to a booted iOS simulator. Useful for testing
# BlueskyNSE and useNotificationsHandler without a real APNS round-trip.
#
# Usage:
# scripts/push-test/send.sh <payload-name> [--did <did>] [--device <udid>] [--bundle <id>]
#
# Examples:
# scripts/push-test/send.sh like --did did:plc:abc123
# BLUESKY_TEST_DID=did:plc:abc123 scripts/push-test/send.sh chat-message
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PAYLOAD_DIR="$SCRIPT_DIR/payloads"
device="booted"
bundle="xyz.blueskyweb.app"
did="${BLUESKY_TEST_DID:-}"
name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--did) did="$2"; shift 2;;
--device) device="$2"; shift 2;;
--bundle) bundle="$2"; shift 2;;
-h|--help)
cat <<EOF
Usage: $0 <payload-name> [--did <did>] [--device <udid>] [--bundle <id>]
Available payloads:
$(ls "$PAYLOAD_DIR" 2>/dev/null | sed 's/\.apns$//' | sed 's/^/ /')
Pass --did or set BLUESKY_TEST_DID to substitute the recipient DID. The DID
must match the account currently signed in to the app, otherwise chat
notifications will trigger an account-switch flow and other reasons will be
silently dropped by the handler.
EOF
exit 0
;;
*)
if [[ -z "$name" ]]; then
name="$1"
else
echo "Unknown arg: $1" >&2
exit 1
fi
shift
;;
esac
done
if [[ -z "$name" ]]; then
echo "Error: payload name required. Run with --help for options." >&2
exit 1
fi
src="$PAYLOAD_DIR/${name}.apns"
if [[ ! -f "$src" ]]; then
echo "Error: payload not found: $src" >&2
echo "Available:" >&2
ls "$PAYLOAD_DIR" | sed 's/\.apns$//' | sed 's/^/ /' >&2
exit 1
fi
if [[ -z "$did" ]]; then
echo "Error: missing recipient DID. Pass --did or set BLUESKY_TEST_DID." >&2
echo " The DID must match the account currently signed in to the app." >&2
exit 1
fi
tmp="$(mktemp -t bluesky-push.XXXXXX).apns"
trap 'rm -f "$tmp"' EXIT
sed "s|__RECIPIENT_DID__|$did|g" "$src" > "$tmp"
echo "Pushing '$name' to $device ($bundle)"
xcrun simctl push "$device" "$bundle" "$tmp"
echo "Delivered."
+1 -1
View File
@@ -31,7 +31,6 @@ import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
@@ -62,6 +61,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
+1 -1
View File
@@ -24,7 +24,6 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
@@ -54,6 +53,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
+31 -4
View File
@@ -4,6 +4,7 @@ import {
type AppBskyAgeassuranceGetConfig,
type AppBskyAgeassuranceGetState,
AtpAgent,
type ChatBskyActorDeclaration,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
@@ -19,6 +20,7 @@ import {
hasSnoozedBirthdateUpdateForDid,
snoozeBirthdateUpdateAllowedForDid,
} from '#/state/birthdate'
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
@@ -53,7 +55,7 @@ const [, cacheHydrationPromise] = persistQueryClient({
persister,
})
function getDidFromAgentSession(agent: AtpAgent) {
export function getDidFromAgentSession(agent: AtpAgent) {
const sessionManager = agent.sessionManager
if (!sessionManager || !sessionManager.did) return
return sessionManager.did
@@ -329,19 +331,25 @@ export function useServerStateQuery() {
export type OtherRequiredData = {
birthdate: string | undefined
actorDeclaration?: ChatBskyActorDeclaration.Main
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did]
}
export async function getOtherRequiredData({
async function getOtherRequiredData({
agent,
}: {
agent: AtpAgent
}): Promise<OtherRequiredData> {
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
const [prefs] = await Promise.all([agent.getPreferences()])
const did = getDidFromAgentSession(agent)
const [prefs, actorDeclaration] = await Promise.all([
agent.getPreferences(),
fetchActorDeclarationRecord({did, agent}),
])
const data: OtherRequiredData = {
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
actorDeclaration,
}
/**
@@ -359,7 +367,6 @@ export async function getOtherRequiredData({
}
}
const did = getDidFromAgentSession(agent)
if (data && did && birthdateCache.has(did)) {
/*
* If birthdate was just set, use the local cache value. On subsequent
@@ -394,6 +401,26 @@ export function getOtherRequiredDataFromCache({
createOtherRequiredDataQueryKey({did}),
)
}
export function setOtherRequiredDataActorDeclarationCache({
did,
actorDeclaration,
}: {
did: string
actorDeclaration: ChatBskyActorDeclaration.Main
}) {
const prev = getOtherRequiredDataFromCache({did})
const next: OtherRequiredData = {
birthdate: prev?.birthdate,
actorDeclaration: {
...(prev?.actorDeclaration || {}),
...actorDeclaration,
},
}
qc.setQueryData<OtherRequiredData>(
createOtherRequiredDataQueryKey({did}),
next,
)
}
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
+9 -4
View File
@@ -1,6 +1,7 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {
AgeAssuranceDataProvider,
@@ -18,6 +19,7 @@ import {
} from '#/ageAssurance/types'
import {
isUnderAge,
maybeRestrictChatSettings,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
@@ -78,6 +80,7 @@ export function Provider({children}: {children: React.ReactNode}) {
}
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
@@ -85,11 +88,13 @@ function InnerProvider({children}: {children: React.ReactNode}) {
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
void getAndRegisterPushToken({
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
})
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
if (isAgeRestricted) {
void getAndRegisterPushToken({isAgeRestricted})
maybeRestrictChatSettings({agent})
}
},
[getAndRegisterPushToken],
[agent, getAndRegisterPushToken],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
+140 -71
View File
@@ -1,8 +1,15 @@
import {useEffect, useMemo, useState} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {
type AgeAssuranceData,
getConfigFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
@@ -12,82 +19,144 @@ import {
parseStatusFromString,
} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {useGeolocation} from '#/geolocation'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
/**
* Get final evaluated age assurance state. Handles fallbacks and defers to
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
export function computeAgeAssuranceState({
hasSession,
config,
geolocation,
state,
data,
}: {
hasSession: boolean
config: AgeAssuranceData['config']
geolocation: Geolocation
state: AgeAssuranceData['state']
data: AgeAssuranceData['data']
}) {
/**
* This is where we control logged-out moderation prefs. It's all
* downstream of AA now.
*/
if (!hasSession)
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
/**
* 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.Safe,
error: 'config' as const,
}
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const isAARequired = region.countryCode !== '*'
const isTerminalState =
state?.status === 'assured' || state?.status === 'blocked'
/*
* If we are in a terminal state and AA is required for this region,
* we can trust the server state completely and avoid recomputing.
*/
if (isTerminalState && isAARequired) {
return {
lastInitiatedAt: state.lastInitiatedAt,
status: parseStatusFromString(state.status),
access: parseAccessFromString(state.access),
}
}
/*
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, data)
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
status: state?.status
? parseStatusFromString(state?.status)
: AgeAssuranceStatus.Unknown,
// prefer server state
access: result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('debug useAgeAssuranceState', {
region,
state,
data,
computed,
})
return computed
}
/**
* This is a last-ditch helper for out-of-band reads of the AA state, such as
* during account creation. Don't use it for anything else.
*/
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const data = getOtherRequiredDataFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !data) {
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
}
return computeAgeAssuranceState({
hasSession: true,
config,
geolocation,
state: state.state,
data: {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
: undefined,
birthdate: data?.birthdate,
},
})
}
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, data} = useAgeAssuranceDataContext()
return useMemo(() => {
/**
* This is where we control logged-out moderation prefs. It's all
* downstream of AA now.
*/
if (!hasSession)
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
/**
* 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.Safe,
error: 'config',
}
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const isAARequired = region.countryCode !== '*'
const isTerminalState =
state?.status === 'assured' || state?.status === 'blocked'
/*
* If we are in a terminal state and AA is required for this region,
* we can trust the server state completely and avoid recomputing.
*/
if (isTerminalState && isAARequired) {
return {
lastInitiatedAt: state.lastInitiatedAt,
status: parseStatusFromString(state.status),
access: parseAccessFromString(state.access),
}
}
/*
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, data)
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
status: state?.status
? parseStatusFromString(state?.status)
: AgeAssuranceStatus.Unknown,
// prefer server state
access: result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('debug useAgeAssuranceState', {
region,
state,
data,
computed,
})
return computed
}, [hasSession, geolocation, config, state, data])
return useMemo(
() =>
computeAgeAssuranceState({
hasSession,
config,
geolocation,
state,
data,
}),
[hasSession, geolocation, config, state, data],
)
}
export function useOnAgeAssuranceAccessUpdate(
+20 -1
View File
@@ -2,13 +2,19 @@ import {useMemo} from 'react'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AtpAgent,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
@@ -109,3 +115,16 @@ export const makeAgeRestrictedModerationPrefs = (
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
/**
* Checks our cache of the actor's chat declaration record, and if it's not
* already restricted, restricts it.
*/
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
const data = getOtherRequiredDataFromCache({did})
// ...update the chat setting record if allowIncoming is not already 'none'.
if (data?.actorDeclaration?.allowIncoming === 'none') return
restrictChatSettings({agent, did})
}
+2 -3
View File
@@ -1,4 +1,4 @@
import {type TextStyle} from 'react-native'
import {type FontVariant, type TextStyle} from 'react-native'
import {IS_ANDROID, IS_WEB} from '#/env'
import {type Device, device} from '#/storage'
@@ -81,10 +81,9 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
*/
if (IS_WEB) {
// @ts-expect-error - web supports 'unicode' as a valid value for fontVariant
style.fontVariant = (style.fontVariant || []).concat(
'no-contextual',
'unicode',
'unicode' as FontVariant, // web supports 'unicode' as a valid value for fontVariant
)
} else {
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
+1
View File
@@ -0,0 +1 @@
export * from '@bsky.app/alf/dist/utils'
+2 -3
View File
@@ -14,8 +14,7 @@ const CACHE = new MMKV({id: 'bsky_features_cache'})
setPolyfills({
localStorage: {
getItem: key => {
const value = CACHE.getString(key)
return value != null ? JSON.parse(value) : null
return CACHE.getString(key) ?? null
},
setItem: async (key, value) => {
CACHE.set(key, value)
@@ -29,7 +28,7 @@ setPolyfills({
*/
export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_INIT = 2000 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500
+3
View File
@@ -12,7 +12,10 @@ export enum Features {
ImageUploadsHighResolution = 'image_uploads:high_resolution',
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test',
+106 -3
View File
@@ -449,6 +449,7 @@ export type Events = {
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
| 'GroupChat'
}
'profile:followers:view': {
contextProfileDid: string
@@ -487,6 +488,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search'
recId?: number | string
position: number
suggestedDid: string
@@ -514,6 +516,7 @@ export type Events = {
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
recSource?: 'Search'
recId?: number | string
position: number
suggestedDid: string
@@ -552,9 +555,14 @@ export type Events = {
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
| 'GroupChat'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'chat:open': {
logContext:
@@ -562,6 +570,7 @@ export type Events = {
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'groupchat:create': {
logContext: 'NewChatDialog'
@@ -798,6 +807,100 @@ export type Events = {
*/
resultSourceLanguage: string
}
'composer:language:suggestLanguage': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:acceptSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:declineSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:replyNudgeAccept': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:replyNudgeDecline': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:nudgeUser': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:langSelectorPressed': {
/**
* If the user was nudged by our language detection to update their language
*/
wasNudged: boolean
}
'postMenu:openMuteWordsDialog': {
uri: string
@@ -874,10 +977,10 @@ export type Events = {
'thread:click:hideReplyForMe': {}
'thread:click:hideReplyForEveryone': {}
'thread:preferences:load': {
[key: string]: any
[key: string]: unknown
}
'thread:preferences:update': {
[key: string]: any
[key: string]: unknown
}
'thread:click:headerMenuOpen': {}
'thread:click:editOwnThreadgate': {}
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useMemo} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {keepPreviousData, useQuery} from '@tanstack/react-query'
@@ -90,24 +90,34 @@ export function useAutocomplete({
}
}
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
},
[q, showSearchFallback, moderationOpts],
[q, moderationOpts],
),
placeholderData: keepPreviousData,
})
const items = useMemo(() => {
if (!query.data) {
return []
}
const results = [...query.data]
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
}, [query.data, showSearchFallback, q])
return {
query: q,
items: query.data || [],
items,
}
}
+93 -175
View File
@@ -1,8 +1,8 @@
import {useCallback, useEffect} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useEffect} from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withDelay,
@@ -15,36 +15,32 @@ import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import type * as bsky from '#/types/bsky'
type Layout = {
size: number
x: number
y: number
zIndex?: number
border?: boolean
}
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: 'small' | 'medium' | 'large' | number
size?: number
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
size = 'large',
size = 120,
}: Props) {
const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const containerSize =
typeof size === 'number'
? size
: size === 'small'
? 40
: size === 'medium'
? 56
: 120
const scale =
typeof size === 'number'
? size / 120
: size === 'small'
? 40 / 120
: size === 'medium'
? 56 / 120
: 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
const scale = size / 120
const marginOffset = size < 120 ? -2 : 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
@@ -52,130 +48,50 @@ export function AvatarBubbles({
const p2 = useSharedValue(initialValue)
const p3 = useSharedValue(initialValue)
const animateScale = (p: Animated.SharedValue<number>, index: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + index * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
const playScaleAnimation = useCallback(() => {
animateScale(p0, 0)
animateScale(p1, 1)
animateScale(p2, 2)
animateScale(p3, 3)
}, [p0, p1, p2, p3])
useEffect(() => {
if (!animate) return
playScaleAnimation()
}, [animate, playScaleAnimation])
const animateBubble = (p: SharedValue<number>, i: number) => {
p.set(0)
p.set(() =>
withDelay(
500 + i * 100,
withTiming(1, {
duration: 250,
easing: Easing.out(Easing.back(1.75)),
}),
),
)
}
animateBubble(p0, 0)
animateBubble(p1, 1)
animateBubble(p2, 2)
animateBubble(p3, 3)
}, [animate, p0, p1, p2, p3])
let avatars = (
<>
<AvatarBubble
profile={profiles[0] ?? allProfiles[0]}
scale={p0}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
includeProfileBorder
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={76}
x={42}
y={42}
style={[a.z_10]}
includeProfileBorder
/>
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={38}
y={62}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={46}
x={71}
y={18}
/>
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble
profile={profiles[0]}
scale={p0}
size={68}
x={-2}
y={-2}
/>
<AvatarBubble
profile={profiles[1]}
scale={p1}
size={56}
x={60}
y={49}
/>
<AvatarBubble
profile={profiles[2]}
scale={p2}
size={42}
x={14}
y={74}
/>
<AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
</>
)
}
const scales = [p0, p1, p2, p3]
const layouts = getLayouts(profiles.length)
return (
<Animated.View
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<Animated.View style={[a.p_2xs, {height: size, width: size}]}>
<View
style={[
{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
},
]}>
{avatars}
style={{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
}}>
{layouts.map((layout, i) => (
<AvatarBubble
key={i}
profile={profiles[i]}
scale={scales[i]}
size={layout.size}
x={layout.x}
y={layout.y}
zIndex={layout.zIndex}
includeProfileBorder={layout.border}
/>
))}
</View>
</Animated.View>
)
@@ -185,27 +101,23 @@ function AvatarBubble({
profile,
scale,
size,
style,
x,
y,
zIndex,
includeProfileBorder,
}: {
profile?: bsky.profile.AnyProfileView
scale: Animated.SharedValue<number>
scale: SharedValue<number>
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
zIndex?: number
includeProfileBorder?: boolean
}) {
const t = useTheme()
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: x},
{translateY: y},
{scale: interpolate(scale.get(), [0, 1], [0, 1])},
],
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
}))
return (
@@ -214,16 +126,21 @@ function AvatarBubble({
a.absolute,
a.rounded_full,
a.flex_grow_0,
{transform: [{translateX: x}, {translateY: y}]},
includeProfileBorder && {
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
},
style,
zIndex != null && {zIndex},
animatedStyle,
]}>
{profile ? (
<Avatar profile={profile} size={size} />
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
) : (
<AvatarPlaceholder size={size} />
)}
@@ -231,25 +148,7 @@ function AvatarBubble({
)
}
function Avatar({
profile,
size = 76,
}: {
profile: bsky.profile.AnyProfileView
size?: number
}) {
return (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
/>
)
}
function AvatarPlaceholder({size = 76}: {size?: number}) {
function AvatarPlaceholder({size}: {size: number}) {
const t = useTheme()
return (
@@ -259,10 +158,7 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
width: size,
height: size,
},
{width: size, height: size},
]}>
<PersonIcon
width={size * 0.5}
@@ -272,3 +168,25 @@ function AvatarPlaceholder({size = 76}: {size?: number}) {
</View>
)
}
function getLayouts(count: number): Layout[] {
if (count === 3) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 38, y: 62},
{size: 46, x: 71, y: 18},
]
}
if (count >= 4) {
return [
{size: 68, x: -2, y: -2},
{size: 56, x: 60, y: 49},
{size: 42, x: 14, y: 74},
{size: 32, x: 72, y: 9},
]
}
return [
{size: 76, x: -2, y: -2, zIndex: 20, border: true},
{size: 76, x: 42, y: 42, zIndex: 10, border: true},
]
}
+6
View File
@@ -77,6 +77,10 @@ export type ButtonState = {
focused: boolean
pressed: boolean
disabled: boolean
/**
* Alias for hovered || focused || pressed
*/
interacting: boolean
}
export type ButtonContext = VariantProps & ButtonState
@@ -120,6 +124,7 @@ const Context = createContext<VariantProps & ButtonState>({
focused: false,
pressed: false,
disabled: false,
interacting: false,
})
Context.displayName = 'ButtonContext'
@@ -536,6 +541,7 @@ export const Button = forwardRef<View, ButtonProps>(
const context = useMemo<ButtonContext>(
() => ({
...state,
interacting: state.hovered || state.focused || state.pressed,
variant,
color,
size,
+18 -7
View File
@@ -54,7 +54,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {atoms as a, flatten, platform, tokens, useTheme} from '#/alf'
import {
Context,
ItemContext,
@@ -527,7 +527,8 @@ export function AuxiliaryView({
}
})
const menuContext = useMemo(() => ({align}), [align])
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
const onLayout = useCallback(() => {
if (!measurement) return
@@ -655,7 +656,8 @@ export function Outer({
[context.measurement, frame.height, insets, translationSV],
)
const menuContext = useMemo(() => ({align}), [align])
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
if (!context.isOpen || !context.measurement) return null
@@ -763,7 +765,7 @@ export function Item({
onOut: onPressOut,
} = useInteractionState()
const id = useId()
const {align} = useContextMenuMenuContext()
const {align, xOffset: menuXOffset} = useContextMenuMenuContext()
const {close, measurement, registerHoverable} = context
@@ -779,8 +781,8 @@ export function Item({
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
? measurement.x + menuXOffset
: measurement.x + measurement.width - layout.width - menuXOffset
registerHoverable(
id,
@@ -796,7 +798,16 @@ export function Item({
},
)
},
[id, measurement, registerHoverable, close, onPress, align, position],
[
id,
measurement,
registerHoverable,
close,
onPress,
align,
menuXOffset,
position,
],
)
const itemContext = useMemo(
+1
View File
@@ -65,6 +65,7 @@ export type ContextType = {
export type MenuContextType = {
align: 'left' | 'right'
xOffset: number
}
export type ItemContextType = {
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: false,
isHeightConstrained: false,
})
Context.displayName = 'DialogContext'
+11 -4
View File
@@ -157,6 +157,8 @@ export function Outer({
[open, close],
)
const isHeightConstrained = nativeOptions?.maxHeight != null
const context = useMemo(
() => ({
close,
@@ -165,8 +167,9 @@ export function Outer({
disableDrag,
setDisableDrag,
isWithinDialog: true,
isHeightConstrained,
}),
[close, snapPoint, disableDrag, setDisableDrag],
[close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
)
return (
@@ -180,7 +183,9 @@ export function Outer({
onStateChange={onStateChange}
disableDrag={disableDrag}>
<Context.Provider value={context}>
<View testID={testID} style={[a.relative]}>
<View
testID={testID}
style={[a.relative, isHeightConstrained && a.flex_1]}>
{children}
</View>
</Context.Provider>
@@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, ...props},
{children, contentContainerStyle, header, style, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = useState(() =>
@@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
return (
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
+2
View File
@@ -111,6 +111,7 @@ export function Outer({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
isHeightConstrained: false,
}),
[close],
)
@@ -196,6 +197,7 @@ export function Inner({
a.border,
t.atoms.bg,
{
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
maxWidth: 600,
borderColor: t.palette.contrast_200,
shadowColor: t.palette.black,
+1
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
isHeightConstrained: boolean
}
export type DialogControlOpenOptions = {
+40
View File
@@ -0,0 +1,40 @@
import {type PickerProps, type RootProps, type TriggerProps} from './types'
export * from './types'
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root(_props: RootProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(_props: TriggerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker(_props: PickerProps): React.ReactNode {
throw new Error('EmojiPopup is not implemented on native')
}
+150
View File
@@ -0,0 +1,150 @@
import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
import EmojiPicker from '@emoji-mart/react'
import {DropdownMenu} from 'radix-ui'
import {useA11y} from '#/state/a11y'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a, flatten} from '#/alf'
import * as Menu from '../Menu'
import {useWebPreloadEmoji} from './preload'
import {
type Emoji,
type PickerProps,
type RootProps,
type TriggerProps,
} from './types'
export * from './types'
const EmojiPickerContext = createContext<{
onEmojiSelect: (emoji: Emoji) => void
nextFocusRef: RootProps['nextFocusRef']
} | null>(null)
/**
* Provides emoji picker context and wraps children in a {@link Menu.Root}.
*
* On emoji select, fires a `textInputWebEmitter` event (for web text inputs
* that listen for emoji insertions) and forwards to the optional
* `onEmojiSelect` callback.
*
* @platform web
*/
export function Root({
children,
control,
onEmojiSelect,
preloadOnMount = true,
nextFocusRef,
}: RootProps) {
useWebPreloadEmoji({immediate: preloadOnMount})
const value = useMemo(
() => ({
onEmojiSelect: (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
if (onEmojiSelect) onEmojiSelect(emoji)
},
nextFocusRef,
}),
[onEmojiSelect, nextFocusRef],
)
return (
<EmojiPickerContext value={value}>
<Menu.Root control={control}>{children}</Menu.Root>
</EmojiPickerContext>
)
}
/**
* Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
* pattern.
*
* @platform web
*/
export function Trigger(props: TriggerProps) {
return <Menu.Trigger {...props} />
}
/**
* Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
*
* Holding Shift while selecting an emoji keeps the picker open for
* multi-select. Otherwise the menu closes after each selection.
*
* Must be rendered inside a {@link Root}.
*
* @platform web
*/
export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
const {control} = Menu.useMenuContext()
const {reduceMotionEnabled} = useA11y()
const isShiftDown = useRef(false)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = false
}
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('keyup', onKeyUp, true)
return () => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('keyup', onKeyUp, true)
}
}, [])
return (
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}}
className="dropdown-menu-transform-origin dropdown-menu-constrain-size"
onCloseAutoFocus={evt => {
if (!nextFocusRef) return
let element =
nextFocusRef instanceof Function
? nextFocusRef()
: nextFocusRef.current
if (element) {
evt.preventDefault()
element.focus()
}
}}>
<div
onWheel={evt => evt.stopPropagation()}
style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}>
<EmojiPicker
autoFocus
onEmojiSelect={(emoji: Emoji) => {
onEmojiSelect(emoji)
if (!keepOpenWhenShiftHeld || !isShiftDown.current) {
control.close()
}
}}
/>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)
}
function useEmojiPickerContext() {
const ctx = useContext(EmojiPickerContext)
if (!ctx)
throw new Error(
'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
)
return ctx
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Native no-op. Emoji data preloading is only needed on web where the picker
* uses `emoji-mart`.
*/
export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
return () => Promise.resolve()
}
@@ -7,8 +7,14 @@ import {init} from 'emoji-mart'
let loadRequested = false
/**
* Preload the emoji picker data to prevent flash.
* {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194}
* Preloads emoji-mart data so the picker renders instantly when opened.
*
* Returns a function that can be called manually to trigger preloading (e.g.
* on hover). When `immediate` is `true`, preloading starts on mount.
*
* Data is only fetched once per page load subsequent calls are no-ops.
*
* @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
*/
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => {
+65
View File
@@ -0,0 +1,65 @@
import {type DialogControlProps} from '../Dialog'
import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
/**
* Represents an emoji selected from the picker. Sourced from the `emoji-mart`
* library's selection data.
*/
export type Emoji = {
aliases?: string[]
emoticons: string[]
id: string
keywords: string[]
name: string
/** The native unicode character for the emoji, e.g. "😀" */
native: string
shortcodes?: string
/** The unicode codepoint, e.g. "1f600" */
unified: string
/** Skin tone variant (16), if applicable */
skin?: number
}
type FocusableElement = {focus: () => void}
export interface RootProps {
children: React.ReactNode
control?: DialogControlProps
/**
* Called when the user selects an emoji. On web this fires in addition to
* the `textInputWebEmitter` event, so callers that only need the text
* insertion can omit this.
*/
onEmojiSelect?: (emoji: Emoji) => void
/**
* When `true` (default), preloads emoji data as soon as the component
* mounts so the picker opens instantly. Set to `false` to defer loading
* until the picker is actually opened.
*/
preloadOnMount?: boolean
/**
* Element to return focus to when the picker closes. Accepts either a ref
* or a getter function.
*/
nextFocusRef?:
| React.RefObject<FocusableElement | null>
| (() => FocusableElement | null | undefined)
}
/**
* Props for the trigger button that opens the emoji picker. Extends
* {@link MenuTriggerProps} accepts the same render-prop children pattern.
*/
export interface TriggerProps extends MenuTriggerProps {}
/**
* Props for the picker panel itself.
*/
export interface PickerProps {
/**
* When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
*
* @default true
*/
keepOpenWhenShiftHeld?: boolean
}
+2 -4
View File
@@ -60,8 +60,7 @@ export function Error({
color="primary"
label={_(msg`Press to retry`)}
onPress={onRetry}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
size="large">
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -73,8 +72,7 @@ export function Error({
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={goBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
+1
View File
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
return (
<ProfileGrid
recId={data?.recId}
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
+13 -2
View File
@@ -1,6 +1,11 @@
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import {
type StyleProp,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewProps,
@@ -11,6 +16,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {
atoms as a,
@@ -30,6 +36,7 @@ export * as Header from '#/components/Layout/Header'
export type ScreenProps = React.ComponentProps<typeof View> & {
style?: StyleProp<ViewStyle>
noInsetTop?: boolean
minimalShell?: boolean
}
/**
@@ -38,9 +45,13 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
export const Screen = memo(function Screen({
style,
noInsetTop,
minimalShell = false,
...props
}: ScreenProps) {
const {top} = useSafeAreaInsets()
useEnableMinimalShellModeForScreen({enabled: minimalShell})
return (
<>
{IS_WEB && <WebCenterBorders />}
@@ -2,8 +2,8 @@ import {useCallback} from 'react'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import ImageView from './ImageViewing'
import ImageView from '#/components/Lightbox/pager/ImagePager'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
export function Lightbox() {
const {activeLightbox} = useLightbox()
@@ -9,7 +9,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {useA11y} from '#/state/a11y'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {
atoms as a,
flatten,
@@ -19,6 +18,7 @@ import {
} from '#/alf'
import {Button} from '#/components/Button'
import {Backdrop} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
@@ -26,11 +26,14 @@ import {
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {CircleChromeButton} from '#/components/Lightbox/chrome/CircleChromeButton'
import {PagerDots} from '#/components/Lightbox/chrome/PagerDots'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
import {type ImageSource} from '#/components/Lightbox/types'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {type ImageSource} from './ImageViewing/@types'
export function Lightbox() {
const {activeLightbox} = useLightbox()
@@ -225,7 +228,13 @@ function LightboxGallery({
)}
</View>
{img.alt ? (
<View style={[a.px_4xl, a.py_2xl, t.atoms.bg, delayedFadeInAnim]}>
<View
style={[
a.px_4xl,
a.py_2xl,
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
delayedFadeInAnim,
]}>
<Pressable
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint={_(
@@ -235,7 +244,7 @@ function LightboxGallery({
setAltExpanded(!isAltExpanded)
}}>
<Text
style={[a.text_md, a.leading_snug]}
style={[a.text_md, a.leading_snug, {color: '#fff'}]}
numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail">
{img.alt}
@@ -251,29 +260,51 @@ function LightboxGallery({
<Menu.Root>
<Menu.Trigger label={_(msg`Image options`)}>
{({props}) => (
<Button
<Pressable
{...props}
style={[
a.absolute,
styles.menuBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Image options`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<EllipsisIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
accessible={false}
style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
<CircleChromeButton
icon={EllipsisIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={_(msg`Image options`)}
/>
</Button>
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Share image`)}
onPress={async () => {
const url = img.uri
if (
typeof navigator !== 'undefined' &&
'share' in navigator &&
navigator.share
) {
try {
await navigator.share({url})
} catch {
// User cancelled or share failed; no-op
}
} else if (
typeof navigator !== 'undefined' &&
navigator.clipboard
) {
try {
await navigator.clipboard.writeText(url)
Toast.show(_(msg`Link copied to clipboard`))
} catch {
Toast.show(_(msg`Failed to copy link`), {type: 'error'})
}
}
}}>
<Menu.ItemText>
<Trans>Share image</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ShareIcon} position="right" />
</Menu.Item>
<Menu.Item
label={_(msg`Download image`)}
onPress={() => {
@@ -294,22 +325,20 @@ function LightboxGallery({
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Button
onPress={onClose}
style={[
a.absolute,
styles.closeBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Close image viewer`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
</Button>
<View style={[a.absolute, styles.closeBtn, delayedFadeInAnim]}>
<CircleChromeButton
icon={XIcon}
label={_(msg`Close image viewer`)}
onPress={onClose}
/>
</View>
{imgs.length > 1 && (
<View
style={[a.absolute, styles.pagerDots, delayedFadeInAnim]}
pointerEvents="none">
<PagerDots count={imgs.length} activeIndex={index} />
</View>
)}
</View>
)
}
@@ -429,6 +458,14 @@ const styles = StyleSheet.create({
top: 20,
right: 20,
},
pagerDots: {
top: 20,
left: 0,
right: 0,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
leftBtn: {
left: 20,
right: 'auto',
@@ -0,0 +1,65 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {BlurView} from 'expo-blur'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<BlurView intensity={20} tint="dark" style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</BlurView>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
pressed: {
opacity: 0.85,
},
})
@@ -0,0 +1,68 @@
import {type ComponentType} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
StyleSheet,
type TextStyle,
View,
} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {type Props as IconProps} from '#/components/icons/common'
type Props = {
icon: ComponentType<IconProps>
iconStyle?: StyleProp<TextStyle>
label: string
onPress?: PressableProps['onPress']
testID?: string
}
const SIZE = 44
const RADIUS = 24
const ICON = 24
export function CircleChromeButton({
icon: Icon,
iconStyle,
label,
onPress,
testID,
}: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={onPress}
testID={testID}
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
<View style={styles.inner}>
<Icon width={ICON} fill="#fff" style={iconStyle} />
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
root: {
width: SIZE,
height: SIZE,
borderRadius: RADIUS,
overflow: 'hidden',
},
inner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
// @ts-expect-error web-only
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
},
pressed: {
opacity: 0.85,
},
})
+84
View File
@@ -0,0 +1,84 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
type Props = {
altText: string | undefined
isAltExpanded: boolean
onToggleAltExpanded: () => void
}
export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
const isMomentumScrolling = useRef(false)
if (!altText) return null
return (
<View
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
pointerEvents="box-none">
<View style={[a.mx_md, styles.altWrap]}>
<ScrollView
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, styles.altText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
</View>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
altText: {
color: '#fff',
},
})
+59
View File
@@ -0,0 +1,59 @@
import {StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {CircleChromeButton} from './CircleChromeButton'
import {ImageMenu} from './ImageMenu'
import {PagerDots} from './PagerDots'
type Props = {
onRequestClose: () => void
onPressShare: () => void
onPressSave: () => void
imageCount: number
activeIndex: number
}
export function Header({
onRequestClose,
onPressShare,
onPressSave,
imageCount,
activeIndex,
}: Props) {
const {_} = useLingui()
const insets = useSafeAreaInsets()
return (
<View
style={[
styles.root,
a.flex_row,
a.justify_between,
a.align_center,
a.px_md,
{paddingTop: insets.top + 8},
]}
pointerEvents="box-none">
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
<PagerDots count={imageCount} activeIndex={activeIndex} />
<CircleChromeButton
icon={CloseIcon}
label={_(msg`Close image`)}
onPress={onRequestClose}
/>
</View>
)
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
})
@@ -0,0 +1,195 @@
import {useRef, useState} from 'react'
import {Modal, Pressable, StyleSheet, View} from 'react-native'
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {Text} from '#/components/Typography'
import {CircleChromeButton} from './CircleChromeButton'
type Props = {
onPressShare: () => void
onPressSave: () => void
}
type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6
const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e'
const ITEM_TEXT = '#f9fafb'
const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240}
const TIMING_OUT = {duration: 150}
export function ImageMenu({onPressShare, onPressSave}: Props) {
const {_} = useLingui()
const triggerRef = useRef<View>(null)
const [isMounted, setIsMounted] = useState(false)
const [anchor, setAnchor] = useState<Anchor | null>(null)
const progress = useSharedValue(0)
const open = () => {
triggerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({x, y, width, height})
setIsMounted(true)
progress.set(withSpring(1, SPRING_IN))
})
}
const close = () => {
progress.set(
withTiming(0, TIMING_OUT, finished => {
if (finished) {
runOnJS(setIsMounted)(false)
}
}),
)
}
const runAction = (action: () => void) => {
close()
action()
}
return (
<>
<View ref={triggerRef} collapsable={false}>
<CircleChromeButton
icon={DotsIcon}
iconStyle={{transform: [{rotate: '90deg'}]}}
label={_(msg`Image options`)}
onPress={open}
/>
</View>
<Modal
transparent
visible={isMounted}
animationType="none"
onRequestClose={close}
statusBarTranslucent>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint=""
style={StyleSheet.absoluteFill}
onPress={close}
/>
{anchor && (
<MenuCard anchor={anchor} progress={progress}>
<MenuItem
icon={ShareIcon}
label={_(msg`Share image`)}
onPress={() => runAction(onPressShare)}
/>
<MenuItem
icon={DownloadIcon}
label={_(msg`Save image`)}
onPress={() => runAction(onPressSave)}
/>
</MenuCard>
)}
</Modal>
</>
)
}
function MenuCard({
anchor,
progress,
children,
}: {
anchor: Anchor
progress: ReturnType<typeof useSharedValue<number>>
children: React.ReactNode
}) {
const animatedStyle = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}],
}))
return (
<Animated.View
style={[
a.absolute,
styles.card,
{
top: anchor.y + anchor.height + GAP,
left: anchor.x,
transformOrigin: 'top left',
},
animatedStyle,
]}>
{children}
</Animated.View>
)
}
function MenuItem({
icon: Icon,
label,
onPress,
}: {
icon: React.ComponentType<IconProps>
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint=""
onPress={onPress}
style={({pressed}) => [styles.item, pressed && styles.itemPressed]}>
<Icon width={18} fill={ITEM_TEXT} />
<Text style={styles.itemText}>{label}</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
width: MENU_WIDTH,
padding: 8,
borderRadius: 16,
borderWidth: 0.5,
borderColor: CARD_BORDER,
backgroundColor: CARD_BG,
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.16,
shadowRadius: 20,
elevation: 8,
},
item: {
height: 44,
paddingHorizontal: 12,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
itemPressed: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
itemText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
lineHeight: 19.5,
color: ITEM_TEXT,
},
})
@@ -0,0 +1,54 @@
import {StyleSheet, View} from 'react-native'
import {atoms as a} from '#/alf'
type Props = {
count: number
activeIndex: number
}
const ACTIVE = 6
const INACTIVE = 4
const GAP = 5
export function PagerDots({count, activeIndex}: Props) {
if (count <= 1) return null
return (
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
{Array.from({length: count}).map((_, i) => {
const isActive = i === activeIndex
return (
<View
key={i}
style={[
isActive ? styles.active : styles.inactive,
isActive ? styles.activeDot : styles.inactiveDot,
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
row: {
gap: GAP,
},
activeDot: {
width: ACTIVE,
height: ACTIVE,
borderRadius: ACTIVE / 2,
},
inactiveDot: {
width: INACTIVE,
height: INACTIVE,
borderRadius: INACTIVE / 2,
},
active: {
backgroundColor: '#fff',
},
inactive: {
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
})
+1
View File
@@ -0,0 +1 @@
export {Lightbox} from './Lightbox'
@@ -6,6 +6,7 @@ import {
type PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
type AnimatableValue,
runOnJS,
type SharedValue,
useAnimatedReaction,
@@ -19,8 +20,8 @@ import {Image} from 'expo-image'
import {
type Dimensions as ImageDimensions,
type ImageSource,
type Transform,
} from '../../@types'
type LightboxTransforms,
} from '../../types'
import {
applyRounding,
createTransform,
@@ -29,7 +30,7 @@ import {
prependTransform,
readTransform,
type TransformMatrix,
} from '../../transforms'
} from '../transforms'
const MIN_SCREEN_ZOOM = 2
const MAX_ORIGINAL_IMAGE_ZOOM = 2
@@ -53,15 +54,7 @@ type Props = {
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
transforms: Readonly<SharedValue<LightboxTransforms>>
}
const ImageItem = ({
imageSrc,
@@ -339,11 +332,12 @@ const ImageItem = ({
})
const imageCropStyle = useAnimatedStyle(() => {
const {cropFrameTransform} = transforms.get()
const {cropFrameTransform, borderRadius: br} = transforms.get()
return {
flex: 1,
overflow: 'hidden',
transform: cropFrameTransform,
borderRadius: br,
}
})
@@ -467,7 +461,7 @@ function clampTranslation(
return clampedValue
}
function withClampedSpring(value: any) {
function withClampedSpring<T extends AnimatableValue>(value: T): T {
'worklet'
return withSpring(value, {overshootClamping: true})
}
@@ -29,8 +29,8 @@ import {Image} from 'expo-image'
import {
type Dimensions as ImageDimensions,
type ImageSource,
type Transform,
} from '../../@types'
type LightboxTransforms,
} from '../../types'
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_SCREEN_ZOOM = 2
@@ -52,15 +52,7 @@ type Props = {
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
transforms: Readonly<SharedValue<LightboxTransforms>>
}
const ImageItem = ({
@@ -170,10 +162,11 @@ const ImageItem = ({
const imageCropStyle = useAnimatedStyle(() => {
const screenSize = measureSafeArea()
const {cropFrameTransform} = transforms.get()
const {cropFrameTransform, borderRadius: br} = transforms.get()
return {
overflow: 'hidden',
transform: cropFrameTransform,
borderRadius: br,
width: screenSize.width,
maxHeight: screenSize.height,
alignSelf: 'center',
@@ -9,8 +9,8 @@ import {type Dimensions} from '#/lib/media/types'
import {
type Dimensions as ImageDimensions,
type ImageSource,
type Transform,
} from '../../@types'
type LightboxTransforms,
} from '../../types'
type Props = {
imageSrc: ImageSource
@@ -29,15 +29,7 @@ type Props = {
imageAspect: number | undefined
imageDimensions: ImageDimensions | undefined
dismissSwipePan: PanGesture
transforms: Readonly<
SharedValue<{
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
isResting: boolean
isHidden: boolean
}>
>
transforms: Readonly<SharedValue<LightboxTransforms>>
}
const ImageItem = (_props: Props) => {
@@ -7,24 +7,21 @@
*/
// Original code copied and simplified from the link below as the codebase is currently not maintained:
// https://github.com/jobtoday/react-native-image-viewing
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
PixelRatio,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {PixelRatio, StyleSheet, useWindowDimensions, View} from 'react-native'
import {SystemBars} from 'react-native-edge-to-edge'
import {Gesture} from 'react-native-gesture-handler'
import PagerView from 'react-native-pager-view'
import Animated, {
type AnimatableValue,
type AnimatedRef,
cancelAnimation,
interpolate,
measure,
type MeasuredDimensions,
ReduceMotion,
runOnJS,
runOnUI,
type SharedValue,
useAnimatedReaction,
useAnimatedRef,
@@ -35,24 +32,22 @@ import Animated, {
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import {SafeAreaView} from 'react-native-safe-area-context'
import * as ScreenOrientation from 'expo-screen-orientation'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {type Lightbox} from '#/state/lightbox'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state'
import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {type ImageSource, type Transform} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer'
import {Header} from '../chrome/Header'
import {
type ImageSource,
type LightboxTransforms,
type Transform,
} from '../types'
import ImageItem from './ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number}
@@ -73,12 +68,11 @@ const FAST_SPRING: WithSpringConfig = {
}
function canAnimate(lightbox: Lightbox): boolean {
return (
!PlatformInfo.getIsReducedMotionEnabled() &&
lightbox.images.every(
img => img.thumbRect && (img.dimensions || img.thumbDimensions),
)
)
if (PlatformInfo.getIsReducedMotionEnabled()) {
return false
}
const img = lightbox.images[lightbox.index]
return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions)
}
export default function ImageViewRoot({
@@ -99,6 +93,9 @@ export default function ImageViewRoot({
'portrait',
)
const openProgress = useSharedValue(0)
const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>(
{},
)
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
@@ -109,6 +106,12 @@ export default function ImageViewRoot({
return
}
const initial: Record<number, MeasuredDimensions | null> = {}
nextLightbox.images.forEach((img, i) => {
initial[i] = img.thumbRect ?? null
})
thumbRects.set(initial)
const isAnimated = canAnimate(nextLightbox)
// https://github.com/software-mansion/react-native-reanimated/issues/6677
@@ -125,13 +128,21 @@ export default function ImageViewRoot({
)
})
}
}, [nextLightbox, openProgress])
}, [nextLightbox, openProgress, thumbRects])
const onFullyClosed = useCallback(() => {
setActiveLightbox(null)
runOnUI(() => {
'worklet'
thumbRects.set({})
})()
}, [thumbRects])
useAnimatedReaction(
() => openProgress.get() === 0,
(isGone, wasGone) => {
if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null)
runOnJS(onFullyClosed)()
}
},
)
@@ -184,6 +195,7 @@ export default function ImageViewRoot({
onFlyAway={onFlyAway}
safeAreaRef={ref}
openProgress={openProgress}
thumbRects={thumbRects}
/>
)}
</Animated.View>
@@ -200,6 +212,7 @@ function ImageView({
onFlyAway,
safeAreaRef,
openProgress,
thumbRects,
}: {
lightbox: Lightbox
orientation: 'portrait' | 'landscape'
@@ -209,6 +222,7 @@ function ImageView({
onFlyAway: () => void
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
const {images, index: initialImageIndex} = lightbox
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
@@ -216,7 +230,7 @@ function ImageView({
const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = useState(false)
const [isAltExpanded, setIsAltExpanded] = useState(false)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
@@ -287,6 +301,24 @@ function ImageView({
}
})
const handleRequestClose = useCallback(() => {
const activeRef = images[imageIndex]?.thumbRef
if (isAnimated && activeRef) {
runOnUI(() => {
'worklet'
const rect = measure(activeRef)
thumbRects.modify(rects => {
'worklet'
rects[imageIndex] = rect
return rects
})
runOnJS(onRequestClose)()
})()
} else {
onRequestClose()
}
}, [isAnimated, images, imageIndex, thumbRects, onRequestClose])
const onTap = useCallback(() => {
setShowControls(show => !show)
}, [])
@@ -350,12 +382,12 @@ function ImageView({
overdrag={true}
style={styles.pager}>
{images.map((imageSrc, i) => (
<View key={imageSrc.uri}>
<View key={`${i}-${imageSrc.uri}`}>
<LightboxImage
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
onRequestClose={handleRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
safeAreaRef={safeAreaRef}
@@ -364,26 +396,33 @@ function ImageView({
isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress}
thumbRects={thumbRects}
imageIndex={i}
/>
</View>
))}
</PagerView>
<View style={styles.controls}>
<View style={styles.controls} pointerEvents="box-none">
<Animated.View
style={animatedHeaderStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} />
<Header
onRequestClose={handleRequestClose}
onPressShare={() => onPressShare(images[imageIndex].uri)}
onPressSave={() => onPressSave(images[imageIndex].uri)}
imageCount={images.length}
activeIndex={imageIndex}
/>
</Animated.View>
<Animated.View
style={animatedFooterStyle}
pointerEvents="box-none"
renderToHardwareTextureAndroid={!isAltExpanded}>
<LightboxFooter
images={images}
index={imageIndex}
<Footer
altText={images[imageIndex].alt}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setAltExpanded(e => !e)}
onPressSave={onPressSave}
onPressShare={onPressShare}
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
/>
</Animated.View>
</View>
@@ -404,6 +443,8 @@ function LightboxImage({
safeAreaRef,
openProgress,
dismissSwipeTranslateY,
thumbRects,
imageIndex,
}: {
imageSrc: ImageSource
onRequestClose: () => void
@@ -417,6 +458,8 @@ function LightboxImage({
safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
imageIndex: number
}) {
const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null)
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
@@ -449,8 +492,8 @@ function LightboxImage({
return safeArea
}, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly])
const {thumbRect} = imageSrc
const transforms = useDerivedValue(() => {
const {thumbRect: thumbRectJS, thumbBorderRadius} = imageSrc
const transforms = useDerivedValue<LightboxTransforms>(() => {
'worklet'
const safeArea = measureSafeArea()
const openProgressValue = openProgress.get()
@@ -461,23 +504,34 @@ function LightboxImage({
return {
isHidden: true,
isResting: false,
borderRadius: 0,
scaleAndMoveTransform: [],
cropFrameTransform: [],
cropContentTransform: [],
}
}
if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
return interpolateTransform(
openProgressValue,
thumbRect,
safeArea,
imageAspect,
)
if (isActive && imageAspect && openProgressValue < 1) {
let thumbRect
if (_WORKLET) {
thumbRect = thumbRects.get()[imageIndex]
} else {
thumbRect = thumbRectJS
}
if (thumbRect) {
return interpolateTransform(
openProgressValue,
thumbRect,
safeArea,
imageAspect,
thumbBorderRadius,
)
}
}
return {
isHidden: false,
isResting: dismissTranslateY === 0,
borderRadius: 0,
scaleAndMoveTransform: [{translateY: dismissTranslateY}],
cropFrameTransform: [],
cropContentTransform: [],
@@ -547,85 +601,6 @@ function LightboxImage({
)
}
function LightboxFooter({
images,
index,
isAltExpanded,
toggleAltExpanded,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
isAltExpanded: boolean
toggleAltExpanded: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const isMomentumScrolling = useRef(false)
return (
<ScrollView
style={styles.footerScrollView}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingVertical: 12,
paddingHorizontal: 24,
}}>
<SafeAreaView edges={['bottom']}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={{color: colors.gray3}}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
toggleAltExpanded()
}}
onLongPress={() => {}}
emoji>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</SafeAreaView>
</ScrollView>
)
}
const styles = StyleSheet.create({
screen: {
position: 'absolute',
@@ -662,41 +637,6 @@ const styles = StyleSheet.create({
pager: {
flex: 1,
},
header: {
position: 'absolute',
width: '100%',
top: 0,
pointerEvents: 'box-none',
},
footer: {
position: 'absolute',
width: '100%',
maxHeight: '100%',
bottom: 0,
},
footerScrollView: {
backgroundColor: '#000d',
flex: 1,
position: 'absolute',
bottom: 0,
width: '100%',
maxHeight: '100%',
},
footerText: {
paddingBottom: IS_IOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
function interpolatePx(
@@ -719,10 +659,12 @@ function interpolateTransform(
},
safeArea: {width: number; height: number; x: number; y: number},
imageAspect: number,
thumbBorderRadius?: number,
): {
scaleAndMoveTransform: Transform
cropFrameTransform: Transform
cropContentTransform: Transform
borderRadius: number
isResting: boolean
isHidden: boolean
} {
@@ -774,16 +716,30 @@ function interpolateTransform(
[0, 1],
[croppedFinalHeight / finalHeight, 1],
)
// The border radius in the source thumbnail needs to be scaled to account
// for the crop frame and overall scale so it visually matches at progress=0.
const sourceBorderRadius = thumbBorderRadius ?? 0
const initialCropScaleX = croppedFinalWidth / finalWidth
const borderRadius = interpolate(
progress,
[0, 1],
[sourceBorderRadius / (initialScale * initialCropScaleX), 0],
)
return {
isHidden: false,
isResting: progress === 1,
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
borderRadius,
}
}
function withClampedSpring(value: any, config: WithSpringConfig) {
function withClampedSpring<T extends AnimatableValue>(
value: T,
config: WithSpringConfig,
): T {
'worklet'
return withSpring(value, {...config, overshootClamping: true})
}
@@ -1,4 +1,4 @@
import {type Position} from './@types'
import {type Position} from '../types'
export type TransformMatrix = [
number,
@@ -1,9 +1,15 @@
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
} from 'react-native-reanimated'
import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
import {type ImageSource} from '#/components/Lightbox/types'
export type Lightbox = {
id: string
@@ -39,17 +45,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
}, [activeLightbox, disableScope, enableScope])
const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => {
setActiveLightbox(prevLightbox => {
if (prevLightbox) {
// Ignore duplicate open requests. If it's already open,
// the user has to explicitly close the previous one first.
return prevLightbox
} else {
return {...lightbox, id: nanoid()}
}
})
})
const openLightbox = useNonReactiveCallback(
(lightbox: Omit<Lightbox, 'id'>) => {
setActiveLightbox(prevLightbox => {
if (prevLightbox) {
// Ignore duplicate open requests. If it's already open,
// the user has to explicitly close the previous one first.
return prevLightbox
} else {
return {...lightbox, id: nanoid()}
const thumbRef = lightbox.images[lightbox.index]?.thumbRef
if (thumbRef) {
// Measure the tapped image on the UI thread, then open with
// the rect baked in so it's available from the first render.
// Only the rect (plain data) goes through runOnJS — AnimatedRef
// objects can't survive serialization across threads.
const openWithRect = (rect: MeasuredDimensions | null) => {
doOpen({
...lightbox,
images: lightbox.images.map((img, i) =>
i === lightbox.index ? {...img, thumbRect: rect} : img,
),
})
}
})
runOnUI(() => {
'worklet'
const rect = measure(thumbRef)
runOnJS(openWithRect)(rect)
})()
} else {
doOpen(lightbox)
}
},
)

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