···11+# BlueskyClip
22+33+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.
44+55+## What It Does
66+77+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:
88+99+1. Loads the starter pack web page in a WKWebView
1010+2. Allows users to browse the starter pack content
1111+3. Presents the App Store overlay when the user decides to join
1212+4. Passes the starter pack URI to the main app via shared UserDefaults
1313+1414+## Architecture
1515+1616+### Native iOS Implementation
1717+1818+The App Clip is a standalone iOS target with its own minimal Swift implementation:
1919+2020+- **AppDelegate.swift**: Standard app delegate that sets up the view controller and handles URL routing (both direct URL opens and universal links)
2121+- **ViewController.swift**: Main view controller that manages the WKWebView, detects starter pack URLs, and communicates with the web layer
2222+2323+### Communication Flow
2424+2525+```
2626+User taps starter pack link
2727+ ↓
2828+iOS presents BlueskyClip App Clip
2929+ ↓
3030+WKWebView loads bsky.app with ?clip=true parameter
3131+ ↓
3232+Web app detects clip mode and sends actions via postMessage
3333+ ↓
3434+ViewController receives messages and:
3535+ - Presents App Store overlay (action: "present")
3636+ - Stores starter pack URI in shared UserDefaults (action: "store")
3737+ ↓
3838+User downloads main app
3939+ ↓
4040+Main app reads starterPackUri from shared UserDefaults
4141+ ↓
4242+Main app displays starter pack onboarding flow
4343+```
4444+4545+### Key Implementation Details
4646+4747+**URL Detection** (`isStarterPackUrl`):
4848+- Matches `bsky.app/start/*` and `bsky.app/starter-pack/*` paths (4 path components)
4949+- Matches short links `go.bsky.app/*` (2 path components)
5050+5151+**WebView Communication** (`WKScriptMessageHandler`):
5252+- Listens for messages on the "onMessage" channel
5353+- Handles two action types:
5454+ - `present`: Shows the App Store overlay using `SKOverlay`
5555+ - `store`: Writes JSON data to shared UserDefaults with the specified key
5656+5757+**Data Sharing**:
5858+- Uses UserDefaults suite `group.app.bsky` (App Group)
5959+- Primary key: `starterPackUri` - stores the starter pack URL
6060+- The main app reads this value on launch via `SharedPrefs.getString('starterPackUri')` (see `src/components/hooks/useStarterPackEntry.native.ts`)
6161+6262+## Configuration
6363+6464+### Build Configuration
6565+6666+The App Clip target is automatically configured via Expo config plugins located in `/plugins/starterPackAppClipExtension/`:
6767+6868+- **withStarterPackAppClip.js**: Main plugin that orchestrates all configuration
6969+- **withXcodeTarget.js**: Creates the App Clip target in Xcode with proper build settings
7070+- **withAppEntitlements.js**: Configures main app entitlements for App Clip association
7171+- **withClipEntitlements.js**: Sets up App Clip entitlements (App Groups, parent app identifier, associated domains)
7272+- **withClipInfoPlist.js**: Generates the Info.plist for the App Clip target
7373+- **withFiles.js**: Copies Swift source files and assets from `modules/BlueskyClip/` to the iOS build directory
7474+7575+### Entitlements
7676+7777+**Main App** (`app.entitlements`):
7878+- `com.apple.security.application-groups`: `group.app.bsky`
7979+- `com.apple.developer.associated-appclip-app-identifiers`: Links to the App Clip bundle ID
8080+8181+**App Clip** (`BlueskyClip.entitlements`):
8282+- `com.apple.security.application-groups`: `group.app.bsky` (for data sharing)
8383+- `com.apple.developer.parent-application-identifiers`: Links to the main app bundle ID
8484+- `com.apple.developer.associated-domains`: Inherits from main app config (for universal links)
8585+8686+### Build Settings
8787+8888+- Deployment target: iOS 15.1+
8989+- Bundle ID: `[main-app-bundle-id].AppClip`
9090+- Product type: `com.apple.product-type.application.on-demand-install-capable`
9191+- Development team: `B3LX46C5HS`
9292+- Device family: iPhone only (1)
9393+9494+## Platform Support
9595+9696+- **iOS**: Full support via native App Clip
9797+- **Android**: Not applicable (no App Clip equivalent)
9898+- **Web**: Not applicable (web uses standard starter pack landing pages)
9999+100100+## Integration with Main App
101101+102102+The main app detects App Clip-originated starter packs through `useStarterPackEntry` hook:
103103+104104+**Native** (`src/components/hooks/useStarterPackEntry.native.ts`):
105105+- Reads `starterPackUri` from `SharedPrefs` (App Group)
106106+- Clears the value after reading to prevent re-use
107107+- Sets active starter pack in app state
108108+109109+**Web** (`src/components/hooks/useStarterPackEntry.ts`):
110110+- Detects `?clip=true` URL parameter
111111+- Extracts starter pack URI from URL
112112+- Sets active starter pack with `isClip: true` flag
113113+114114+## Files
115115+116116+```
117117+modules/BlueskyClip/
118118+├── AppDelegate.swift # App lifecycle and URL handling
119119+├── ViewController.swift # WebView management and message handling
120120+└── Images.xcassets/ # App Clip icon assets
121121+ ├── AppIcon.appiconset/
122122+ │ ├── App-Icon-1024x1024@1x.png
123123+ │ └── Contents.json
124124+ └── Contents.json
125125+```
126126+127127+## Development Notes
128128+129129+- The App Clip is built as part of the main Xcode project when running `yarn prebuild`
130130+- Source files are copied during the prebuild process, not directly referenced
131131+- Changes to Swift files require running `yarn prebuild` to take effect
132132+- The App Clip shares the same version number as the main app
133133+- App Clips have a 15MB size limit (enforced by Apple)
134134+- Users can convert an App Clip session into a full app install without losing data (via shared App Group)
+135
modules/BlueskyNSE/README.md
···11+# BlueskyNSE
22+33+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.
44+55+## What It Does
66+77+This extension intercepts incoming push notifications and performs processing before displaying them:
88+99+1. Manages badge counts for app icon
1010+2. Applies custom notification sounds based on user preferences
1111+3. Enables notification customization without requiring the main app to be running
1212+1313+## How It Works
1414+1515+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.
1616+1717+### Architecture
1818+1919+The extension uses shared UserDefaults (via App Groups) to access preferences set by the main app:
2020+2121+- **App Group**: `group.app.bsky` allows data sharing between the main app and the extension
2222+- **Shared Preferences**: Stored in UserDefaults suite accessible by both processes
2323+- **Thread Safety**: Uses a dedicated serial DispatchQueue (`NSEPrefsQueue`) to prevent race conditions when multiple notifications arrive simultaneously
2424+2525+### Notification Processing Flow
2626+2727+1. System receives push notification
2828+2. `NotificationService.didReceive()` is called
2929+3. Extension creates mutable copy of notification content
3030+4. Based on notification type (determined by `reason` field):
3131+ - **Chat messages** (`reason == "chat-message"`): Applies custom DM sound if user preference `playSoundChat` is enabled
3232+ - **Other notifications**: Increments and applies badge count
3333+5. Extension delivers modified notification to system via `contentHandler`
3434+3535+### Badge Count Management
3636+3737+Badge counts are managed centrally by the extension:
3838+- Each non-chat notification increments the badge count
3939+- Count is synchronized across notification instances using the serial queue
4040+- Main app can reset the count via the `expo-background-notification-handler` module
4141+4242+### Notification Sounds
4343+4444+Two sound types are supported:
4545+- **Default system sound**: Standard iOS notification sound
4646+- **DM sound**: Custom `dm.aiff` sound file for chat messages
4747+4848+DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings.
4949+5050+## Key Files
5151+5252+| File | Purpose |
5353+|------|---------|
5454+| `NotificationService.swift` | Main service extension implementation |
5555+| `BlueskyNSE.entitlements` | iOS entitlements configuration for App Group access |
5656+| `Info.plist` | Extension metadata and configuration |
5757+5858+### NotificationService.swift
5959+6060+Contains two main classes:
6161+6262+**NotificationService**: The main extension class that implements `UNNotificationServiceExtension`
6363+- `didReceive(_:withContentHandler:)`: Processes incoming notifications
6464+- `serviceExtensionTimeWillExpire()`: Handles timeout scenarios
6565+- Mutation methods for modifying notification content
6666+6767+**NSEUtil**: Singleton utility class for shared state management
6868+- Provides shared `UserDefaults` instance for the App Group
6969+- Manages serial queue for thread-safe preference access
7070+- Helper methods for notification content manipulation
7171+7272+## Configuration
7373+7474+### App Group Setup
7575+7676+The extension requires the `group.app.bsky` App Group to be configured in:
7777+1. Main app target capabilities
7878+2. Extension target capabilities (defined in `BlueskyNSE.entitlements`)
7979+8080+### Shared Preferences
8181+8282+The following preferences are shared between the main app and extension:
8383+8484+| Preference Key | Type | Purpose |
8585+|----------------|------|---------|
8686+| `badgeCount` | Int | Current badge count for app icon |
8787+| `playSoundChat` | Bool | Whether to play sound for chat notifications |
8888+8989+These are managed by the `expo-background-notification-handler` module in the main app.
9090+9191+### Sound Files
9292+9393+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.
9494+9595+## Platform Support
9696+9797+- **iOS**: Fully supported (primary platform for this extension)
9898+- **Android**: Not applicable (Android uses different notification handling mechanisms)
9999+- **Web**: Not applicable (web notifications are handled by browser APIs)
100100+101101+## Integration with Main App
102102+103103+The extension coordinates with the main app through:
104104+105105+1. **expo-background-notification-handler** module: Provides JavaScript API for managing shared preferences
106106+2. **App Group shared storage**: Enables data synchronization between processes
107107+3. **Push notification payload**: Must include `reason` field to determine notification type
108108+109109+### Setting User Preferences
110110+111111+Users can control notification sounds via the Chat Settings screen (`src/screens/Messages/Settings.tsx`):
112112+113113+```typescript
114114+import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
115115+116116+const {preferences, setPref} = useBackgroundNotificationPreferences()
117117+setPref('playSoundChat', true) // Enable DM sounds
118118+```
119119+120120+## Limitations
121121+122122+1. **Time constraints**: Extension must complete processing within ~30 seconds or the system will terminate it
123123+2. **Process isolation**: Runs in separate process with limited memory and resources
124124+3. **iOS only**: Notification Service Extensions are an iOS-specific feature
125125+4. **Concurrent processing**: Multiple notifications may arrive simultaneously, requiring careful state management
126126+127127+## Best Practices
128128+129129+When modifying this extension:
130130+131131+1. Keep processing fast and synchronous when possible
132132+2. Use the shared serial queue for any UserDefaults mutations
133133+3. Avoid network requests that could cause timeouts
134134+4. Always call `contentHandler` with modified content, even on errors
135135+5. Test with multiple concurrent notifications to verify thread safety
+140
modules/Share-with-Bluesky/README.md
···11+# Share-with-Bluesky
22+33+iOS Share Extension for the Bluesky Social app that enables users to share content from other apps directly to Bluesky.
44+55+## Overview
66+77+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.
88+99+## Features
1010+1111+- Share plain text
1212+- Share URLs (web links)
1313+- Share images (up to 4 images, supports PNG, JPG, JPEG, GIF, HEIC)
1414+- Share videos (single video, supports MOV, MP4, M4V)
1515+- Automatic image dimension extraction
1616+- Automatic video dimension extraction
1717+- App group file sharing for media access
1818+1919+## Architecture
2020+2121+### iOS Share Extension
2222+2323+The extension is implemented as a native iOS Share Extension using Swift. When a user shares content:
2424+2525+1. The `ShareViewController` receives the shared content from the extension context
2626+2. Content is processed based on its type (text, URL, image, or video)
2727+3. Media files are copied to a shared App Group container (`group.app.bsky`) for access by the main app
2828+4. Image and video dimensions are extracted and encoded into the URI
2929+5. The extension constructs a deep link URL with the content encoded in query parameters
3030+6. The main Bluesky app is opened with the deep link
3131+7. The extension completes and dismisses
3232+3333+### Deep Link Format
3434+3535+The extension communicates with the main app using deep links with the `bluesky://` scheme:
3636+3737+```
3838+bluesky://intent/compose?text=<encoded-text>
3939+bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>
4040+bluesky://intent/compose?videoUri=<uri>|<width>|<height>
4141+```
4242+4343+The scheme can be customized by setting the `MainAppScheme` key in `Info.plist` to support forks.
4444+4545+### Main App Integration
4646+4747+The main app handles these deep links in `src/lib/hooks/useIntentHandler.ts`:
4848+4949+- Parses the deep link parameters
5050+- Validates image/video URIs for security (filters out external URLs)
5151+- Opens the composer with the pre-populated content
5252+- Supports up to 4 images or 1 video per share
5353+5454+## Key Files
5555+5656+### Module Files
5757+5858+- `ShareViewController.swift` - Main view controller that handles share requests and processes content
5959+- `Info.plist` - Extension configuration (activation rules, supported content types)
6060+- `Share-with-Bluesky.entitlements` - App group entitlements for shared file access
6161+6262+### App Integration
6363+6464+- `src/lib/hooks/useIntentHandler.ts` - Main app hook that handles incoming deep links
6565+- `android/app/src/main/AndroidManifest.xml` - Android share intent configuration (lines 57-76)
6666+6767+## Configuration
6868+6969+### Supported Content Types
7070+7171+Defined in `Info.plist` under `NSExtensionActivationRule`:
7272+7373+- Text: Plain text strings
7474+- Web URLs: Up to 1 URL
7575+- Images: Up to 10 images
7676+- Videos: Up to 1 video
7777+7878+### App Group
7979+8080+The extension uses the `group.app.bsky` App Group identifier to share files with the main app. This is configured in:
8181+8282+- `Share-with-Bluesky.entitlements`
8383+- Main app's entitlements file
8484+8585+### Custom Scheme
8686+8787+The `MainAppScheme` in `Info.plist` defaults to `bluesky` but can be changed for forks to use a custom URL scheme.
8888+8989+## Platform Support
9090+9191+- iOS: Native Share Extension (this module)
9292+- Android: Native share intents handled via MainActivity intent filters in AndroidManifest.xml
9393+- Web: Not applicable (browser share APIs use different mechanisms)
9494+9595+## Implementation Details
9696+9797+### Image Processing
9898+9999+When images are shared:
100100+101101+1. Images are loaded from the extension's temporary directory or as UIImage objects
102102+2. Images are converted to JPEG format at maximum quality
103103+3. Dimensions are extracted from the UIImage
104104+4. Files are saved to the App Group container with unique names
105105+5. URIs are formatted as `<file-url>|<width>|<height>`
106106+107107+### Video Processing
108108+109109+When videos are shared:
110110+111111+1. Videos are copied from the source URL to the App Group container
112112+2. AVURLAsset is used to extract video track dimensions
113113+3. Track dimensions are adjusted for video rotation using preferredTransform
114114+4. URI is formatted as `<file-url>|<width>|<height>`
115115+116116+### Security
117117+118118+- External URLs in image URIs are filtered out in the main app to prevent potential security issues
119119+- Only file:// URLs from the App Group container are accepted
120120+- URI format is validated with a regex pattern before processing
121121+122122+## Development
123123+124124+This module is built as part of the main Xcode project. The extension target is included in the iOS build configuration.
125125+126126+To modify the extension:
127127+128128+1. Open the Xcode project in `/ios`
129129+2. Navigate to the Share-with-Bluesky target
130130+3. Edit `ShareViewController.swift` for logic changes
131131+4. Edit `Info.plist` for configuration changes
132132+5. Rebuild the iOS app
133133+134134+## Limitations
135135+136136+- Images: Maximum of 4 images per share (limited in main app handler)
137137+- Videos: Only 1 video per share
138138+- Mixed media: Cannot share images and videos together
139139+- File size: No explicit limits, but large files may cause issues
140140+- Formats: Only supports common image/video formats listed in constants
+248
modules/bottom-sheet/README.md
···11+# Bottom Sheet Expo Module
22+33+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).
44+55+## Overview
66+77+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.
88+99+Key features:
1010+- Native bottom sheet presentation on iOS and Android
1111+- Automatic content height detection (no JS bridge round-trip)
1212+- Configurable snap points (hidden, partial, full)
1313+- Drag-to-dismiss with prevention controls
1414+- Portal-based rendering for proper z-index layering
1515+- Edge-to-edge support on modern Android versions
1616+- iOS 26+ zoom transition support
1717+1818+## Platform Support
1919+2020+- **iOS**: Uses `UISheetPresentationController` (iOS 15+)
2121+- **Android**: Uses Material Design `BottomSheetDialog` with `BottomSheetBehavior`
2222+- **Web**: Not supported (throws error)
2323+2424+## Architecture
2525+2626+### TypeScript Layer
2727+2828+The module exposes a React component that handles rendering and state management:
2929+3030+- **BottomSheet.tsx** (Native): Main component wrapping the native view
3131+- **BottomSheet.web.tsx** (Web): Stub that throws an error
3232+- **BottomSheetNativeComponent.tsx**: React wrapper with portal integration
3333+- **BottomSheetPortal.tsx**: Portal system for rendering sheets above app content
3434+- **Portal.tsx**: Generic portal implementation for managing component hierarchy
3535+3636+The component uses a class-based approach to expose imperative methods (`present()`, `dismiss()`, `dismissAll()`).
3737+3838+### Native Layer
3939+4040+#### iOS Implementation
4141+4242+- **BottomSheetModule.swift**: Expo module definition with event handlers and prop bindings
4343+- **SheetView.swift**: Main view component that creates and manages `SheetViewController`
4444+ - Observes content height via KVO (Key-Value Observing) on bounds
4545+ - Manages sheet lifecycle and state transitions
4646+ - Implements `UISheetPresentationControllerDelegate` for drag events
4747+- **SheetViewController.swift**: UIViewController subclass with sheet presentation
4848+ - Configures detents (snap points) based on content height
4949+ - Handles iOS 26+ safe area adjustments for floating sheet style
5050+ - Animates detent changes when content resizes
5151+- **SheetManager.swift**: Singleton that tracks all active sheets with weak references
5252+- **Util.swift**: Helper for calculating screen height minus safe area insets
5353+5454+#### Android Implementation
5555+5656+- **BottomSheetModule.kt**: Expo module definition mirroring iOS functionality
5757+- **BottomSheetView.kt**: Main view component managing Material BottomSheetDialog
5858+ - Uses `OnLayoutChangeListener` to observe content height natively
5959+ - Configures `BottomSheetBehavior` for drag and snap behavior
6060+ - Handles edge-to-edge display across Android versions (API 29-35+)
6161+ - Preserves status/nav bar appearance from host activity
6262+- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
6363+ - Forwards touch events to React Native event system
6464+ - Updates shadow node size to match window dimensions
6565+ - Based on React Native's ReactModalHostView pattern
6666+- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
6767+6868+### Content Height Detection
6969+7070+Both platforms detect content height changes natively without JS bridge round-trips:
7171+7272+- **iOS**: KVO observation on the content view's `bounds` property
7373+- **Android**: `OnLayoutChangeListener` on child views (catches React Native's direct `layout()` calls)
7474+7575+This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
7676+7777+## Props
7878+7979+```typescript
8080+interface BottomSheetViewProps {
8181+ children: React.ReactNode
8282+8383+ // Appearance
8484+ cornerRadius?: number
8585+ backgroundColor?: ColorValue
8686+ containerBackgroundColor?: ColorValue
8787+8888+ // Behavior
8989+ preventDismiss?: boolean // Disable swipe-to-dismiss
9090+ preventExpansion?: boolean // Lock to initial height (no full-screen)
9191+ disableDrag?: boolean // Disable drag handle (Android only)
9292+ fullHeight?: boolean // Start at full screen height
9393+9494+ // Height constraints
9595+ minHeight?: number // Minimum height in dp
9696+ maxHeight?: number // Maximum height in dp
9797+9898+ // iOS 26+ transition
9999+ sourceViewTag?: number // View tag for zoom transition origin
100100+101101+ // Events
102102+ onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
103103+ onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
104104+ onStateChange?: (event: BottomSheetStateChangeEvent) => void
105105+}
106106+```
107107+108108+## States and Snap Points
109109+110110+### States
111111+- `closed`: Sheet is dismissed
112112+- `closing`: Sheet is animating closed
113113+- `open`: Sheet is fully visible
114114+- `opening`: Sheet is animating open
115115+116116+### Snap Points
117117+- `Hidden` (0): Dismissed
118118+- `Partial` (1): Half-expanded / content height
119119+- `Full` (2): Expanded to screen height
120120+121121+## Usage
122122+123123+### Basic Example
124124+125125+```tsx
126126+import {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
127127+128128+// In your app root:
129129+function App() {
130130+ return (
131131+ <BottomSheetProvider>
132132+ <YourApp />
133133+ <BottomSheetOutlet />
134134+ </BottomSheetProvider>
135135+ )
136136+}
137137+138138+// In a component:
139139+function MyComponent() {
140140+ const sheetRef = useRef<BottomSheet>(null)
141141+142142+ const openSheet = () => {
143143+ sheetRef.current?.present()
144144+ }
145145+146146+ const closeSheet = () => {
147147+ sheetRef.current?.dismiss()
148148+ }
149149+150150+ return (
151151+ <>
152152+ <Button onPress={openSheet} title="Open Sheet" />
153153+154154+ <BottomSheet
155155+ ref={sheetRef}
156156+ cornerRadius={16}
157157+ backgroundColor="white"
158158+ onStateChange={(e) => console.log(e.nativeEvent.state)}
159159+ >
160160+ <View style={{padding: 20}}>
161161+ <Text>Sheet content</Text>
162162+ <Button onPress={closeSheet} title="Close" />
163163+ </View>
164164+ </BottomSheet>
165165+ </>
166166+ )
167167+}
168168+```
169169+170170+### Nested Sheets
171171+172172+The module supports nesting sheets by using `BottomSheetPortalProvider` within sheet content:
173173+174174+```tsx
175175+<BottomSheet ref={outerSheetRef}>
176176+ <BottomSheetPortalProvider>
177177+ <Button onPress={() => innerSheetRef.current?.present()} />
178178+ <BottomSheet ref={innerSheetRef}>
179179+ <Text>Inner sheet content</Text>
180180+ </BottomSheet>
181181+ </BottomSheetPortalProvider>
182182+</BottomSheet>
183183+```
184184+185185+### Dismiss All Sheets
186186+187187+```tsx
188188+import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
189189+190190+BottomSheetNativeComponent.dismissAll()
191191+```
192192+193193+## Key Implementation Details
194194+195195+### iOS Specific
196196+197197+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.
198198+199199+2. **iOS 26+ Zoom Transitions**: When `sourceViewTag` is provided on iOS 26+, the sheet zooms from the specified view.
200200+201201+3. **Detent Selection**: The module automatically chooses between custom detents, `.medium()`, and `.large()` based on content height and screen size.
202202+203203+### Android Specific
204204+205205+1. **Edge-to-Edge**: The module handles edge-to-edge display correctly across API levels:
206206+ - API 35+: Mandatory edge-to-edge
207207+ - API 30-34: Uses `currentWindowMetrics`
208208+ - API <30: Uses deprecated `getRealSize()`
209209+210210+2. **Status/Nav Bar Appearance**: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
211211+212212+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).
213213+214214+4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
215215+216216+### Platform Differences
217217+218218+- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
219219+- **disableDrag**: Android-only prop (iOS drag behavior is controlled via `preventDismiss` + `preventExpansion`)
220220+- **sourceViewTag**: iOS 26+ only (ignored on Android)
221221+222222+## Files Reference
223223+224224+### TypeScript
225225+- `index.ts` - Public API exports
226226+- `src/BottomSheet.types.ts` - TypeScript type definitions
227227+- `src/BottomSheet.tsx` - Native component (re-export)
228228+- `src/BottomSheet.web.tsx` - Web stub
229229+- `src/BottomSheetNativeComponent.tsx` - Native wrapper with portal integration
230230+- `src/BottomSheetNativeComponent.web.tsx` - Web stub for native component
231231+- `src/BottomSheetPortal.tsx` - Portal context and providers
232232+- `src/lib/Portal.tsx` - Generic portal implementation
233233+234234+### iOS
235235+- `ios/BottomSheetModule.swift` - Module definition
236236+- `ios/SheetView.swift` - Main view implementation
237237+- `ios/SheetViewController.swift` - View controller for sheet presentation
238238+- `ios/SheetManager.swift` - Singleton for tracking active sheets
239239+- `ios/Util.swift` - Screen height utility
240240+241241+### Android
242242+- `android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt` - Module definition
243243+- `android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt` - Main view implementation
244244+- `android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt` - Dialog root view group
245245+- `android/src/main/java/expo/modules/bottomsheet/SheetManager.kt` - Sheet tracking singleton
246246+247247+### Configuration
248248+- `expo-module.config.json` - Expo module configuration
···11+# expo-background-notification-handler
22+33+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.
44+55+## Purpose
66+77+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.
88+99+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.
1010+1111+## Platform Support
1212+1313+- **iOS**: Full support via UserDefaults with App Groups
1414+- **Android**: Full support via SharedPreferences
1515+- **Web**: Stub implementation (no-op)
1616+1717+## Architecture
1818+1919+### iOS Implementation
2020+2121+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.
2222+2323+**Key Files:**
2424+- `ios/ExpoBackgroundNotificationHandlerModule.swift` - Native module implementation
2525+- `ios/ExpoBackgroundNotificationHandler.podspec` - CocoaPods specification
2626+2727+### Android Implementation
2828+2929+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.
3030+3131+**Key Files:**
3232+- `android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt` - Expo module definition
3333+- `android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt` - SharedPreferences wrapper
3434+- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt` - Notification processing logic
3535+- `android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt` - Interface for showing notifications
3636+- `android/build.gradle` - Build configuration
3737+3838+### TypeScript/React API
3939+4040+**Key Files:**
4141+- `index.ts` - Module entry point
4242+- `src/ExpoBackgroundNotificationHandlerModule.ts` - Native module binding (iOS/Android)
4343+- `src/ExpoBackgroundNotificationHandlerModule.web.ts` - Web stub
4444+- `src/ExpoBackgroundNotificationHandler.types.ts` - TypeScript type definitions
4545+- `src/BackgroundNotificationHandlerProvider.tsx` - React Context provider for preferences
4646+4747+## Stored Preferences
4848+4949+The module manages the following notification preferences:
5050+5151+```typescript
5252+{
5353+ playSoundChat: boolean, // Currently exposed to TypeScript
5454+ playSoundFollow: boolean, // Native only (not yet exposed)
5555+ playSoundLike: boolean, // Native only (not yet exposed)
5656+ playSoundMention: boolean, // Native only (not yet exposed)
5757+ playSoundQuote: boolean, // Native only (not yet exposed)
5858+ playSoundReply: boolean, // Native only (not yet exposed)
5959+ playSoundRepost: boolean, // Native only (not yet exposed)
6060+ mutedThreads: [String: [String]], // iOS only
6161+ badgeCount: number // iOS only
6262+}
6363+```
6464+6565+Default values are initialized when the module is created, with most sound preferences defaulting to `false` except `playSoundChat` which defaults to `true`.
6666+6767+## API
6868+6969+### Core Methods
7070+7171+```typescript
7272+// Get all preferences
7373+getAllPrefsAsync(): Promise<BackgroundNotificationHandlerPreferences>
7474+7575+// Get individual values
7676+getBoolAsync(forKey: string): Promise<boolean>
7777+getStringAsync(forKey: string): Promise<string>
7878+getStringArrayAsync(forKey: string): Promise<string[]>
7979+8080+// Set individual values
8181+setBoolAsync(forKey: string, value: boolean): Promise<void>
8282+setStringAsync(forKey: string, value: string): Promise<void>
8383+setStringArrayAsync(forKey: string, value: string[]): Promise<void>
8484+8585+// Array manipulation
8686+addToStringArrayAsync(forKey: string, value: string): Promise<void>
8787+removeFromStringArrayAsync(forKey: string, value: string): Promise<void>
8888+addManyToStringArrayAsync(forKey: string, value: string[]): Promise<void>
8989+removeManyFromStringArrayAsync(forKey: string, value: string[]): Promise<void>
9090+9191+// Badge count (iOS only)
9292+setBadgeCountAsync(count: number): Promise<void>
9393+```
9494+9595+### React Context API
9696+9797+The module provides a React Context provider for managing preferences in the app:
9898+9999+```typescript
100100+import {
101101+ BackgroundNotificationPreferencesProvider,
102102+ useBackgroundNotificationPreferences,
103103+} from 'expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
104104+105105+function App() {
106106+ return (
107107+ <BackgroundNotificationPreferencesProvider>
108108+ <YourApp />
109109+ </BackgroundNotificationPreferencesProvider>
110110+ )
111111+}
112112+113113+function SettingsScreen() {
114114+ const {preferences, setPref} = useBackgroundNotificationPreferences()
115115+116116+ return (
117117+ <Toggle
118118+ value={preferences.playSoundChat}
119119+ onValueChange={(value) => setPref('playSoundChat', value)}
120120+ />
121121+ )
122122+}
123123+```
124124+125125+## Android Notification Handling
126126+127127+The Android implementation includes logic for processing notifications while the app is backgrounded:
128128+129129+- **Chat messages**: Applies custom notification channels based on `playSoundChat` preference
130130+ - Sound enabled: Uses `chat-messages` channel (or `dm.mp3` sound on older Android)
131131+ - Sound disabled: Uses `chat-messages-muted` channel
132132+133133+- **Other notification types**: On Android Oreo+ (API 26+), assigns notifications to channels based on reason:
134134+ - Supported reasons: `like`, `repost`, `follow`, `mention`, `reply`, `quote`, `like-via-repost`, `repost-via-repost`, `subscribed-post`
135135+ - Each reason maps to its corresponding notification channel
136136+137137+When the app is foregrounded, the module defers to `expo-notifications` for notification handling.
138138+139139+## Configuration
140140+141141+### iOS
142142+143143+Requires App Group entitlement configured in Xcode:
144144+- App Group ID: `group.app.bsky`
145145+146146+### Android
147147+148148+Requires Firebase Cloud Messaging (FCM) integration:
149149+- Dependency: `com.google.firebase:firebase-messaging-ktx:24.0.0`
150150+- SharedPreferences name: `xyz.blueskyweb.app`
151151+152152+## Usage in the App
153153+154154+The module is used to:
155155+156156+1. Store notification preferences that need to be accessed by notification service extensions
157157+2. Track app foreground/background state on Android
158158+3. Process and mutate notification payloads based on user preferences before display
159159+4. Manage notification badge counts on iOS
160160+5. Handle thread muting and other notification filtering logic
161161+162162+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.
+167
modules/expo-bluesky-gif-view/README.md
···11+# expo-bluesky-gif-view
22+33+An Expo module for displaying animated GIFs and WebP images with optimized performance and playback controls.
44+55+## Overview
66+77+This module provides a custom view component for rendering animated GIFs with support for:
88+99+- Autoplay control
1010+- Placeholder images while loading
1111+- Programmatic playback control (play/pause/toggle)
1212+- Image prefetching
1313+- Efficient memory management
1414+- Player state change events
1515+1616+## Platform Support
1717+1818+- iOS (13.4+)
1919+- Android (API 21+)
2020+- Web
2121+2222+## Architecture
2323+2424+The module uses native platform libraries for optimal GIF rendering performance:
2525+2626+### iOS Implementation
2727+2828+- **Library**: SDWebImage with SDWebImageWebPCoder
2929+- **Key Files**:
3030+ - `ios/GifView.swift` - Main view implementation using `SDAnimatedImageView`
3131+ - `ios/ExpoBlueskyGifViewModule.swift` - Module definition and prop bindings
3232+ - `ios/Util.swift` - Cache configuration utilities
3333+3434+**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.
3535+3636+### Android Implementation
3737+3838+- **Library**: Glide
3939+- **Key Files**:
4040+ - `android/src/main/java/expo/modules/blueskygifview/GifView.kt` - Main view implementation
4141+ - `android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt` - Module definition
4242+ - `android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt` - Custom ImageView with playback control
4343+4444+**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.
4545+4646+### Web Implementation
4747+4848+- **Library**: Native HTML5 `<video>` element
4949+- **Key File**: `src/GifView.web.tsx`
5050+5151+**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).
5252+5353+## Usage
5454+5555+```tsx
5656+import {GifView} from 'expo-bluesky-gif-view'
5757+5858+function MyComponent() {
5959+ const gifRef = React.useRef<GifView>(null)
6060+6161+ return (
6262+ <GifView
6363+ source="https://example.com/animated.gif"
6464+ placeholderSource="https://example.com/thumbnail.jpg"
6565+ autoplay={true}
6666+ onPlayerStateChange={(event) => {
6767+ console.log('Playing:', event.nativeEvent.isPlaying)
6868+ console.log('Loaded:', event.nativeEvent.isLoaded)
6969+ }}
7070+ ref={gifRef}
7171+ />
7272+ )
7373+}
7474+```
7575+7676+## API
7777+7878+### Props
7979+8080+- `source?: string` - URL of the animated GIF/WebP
8181+- `placeholderSource?: string` - URL of a static placeholder image to show while loading
8282+- `autoplay?: boolean` - Whether to start playing automatically (default: true)
8383+- `onPlayerStateChange?: (event: GifViewStateChangeEvent) => void` - Callback fired when playback state changes
8484+8585+### Methods
8686+8787+All methods are async and return a Promise:
8888+8989+```tsx
9090+await gifRef.current?.playAsync()
9191+await gifRef.current?.pauseAsync()
9292+await gifRef.current?.toggleAsync()
9393+```
9494+9595+### Static Methods
9696+9797+```tsx
9898+// Prefetch GIFs into the cache (not supported on web)
9999+await GifView.prefetchAsync([
100100+ 'https://example.com/gif1.gif',
101101+ 'https://example.com/gif2.gif'
102102+])
103103+```
104104+105105+## Configuration
106106+107107+### iOS Dependencies
108108+109109+The module requires SDWebImage and SDWebImageWebPCoder:
110110+111111+```ruby
112112+# ios/ExpoBlueskyGifView.podspec
113113+s.dependency 'SDWebImage', '~> 5.21.0'
114114+s.dependency 'SDWebImageWebPCoder', '~> 0.14.6'
115115+```
116116+117117+### Android Dependencies
118118+119119+The module uses Glide, kept in sync with expo-image version:
120120+121121+```gradle
122122+# android/build.gradle
123123+implementation 'com.github.bumptech.glide:glide:4.13.2'
124124+```
125125+126126+## Key Implementation Details
127127+128128+### Lifecycle Management
129129+130130+- **iOS**: Cancels pending requests in `willMove(toWindow:)` when scrolled off-screen
131131+- **Android**: Pauses playback in `onDetachedFromWindow()`, resumes in `onAttachedToWindow()`
132132+- **Web**: Uses React lifecycle methods to manage video element state
133133+134134+### Cache Strategy
135135+136136+- **iOS**: Disk-only caching to work around `SDAnimatedImage` memory issues
137137+- **Android**: DATA disk cache for main images, skips memory cache for placeholders
138138+- **Web**: Relies on browser cache
139139+140140+### Animation Control
141141+142142+- **iOS**: `SDAnimatedImageView.autoPlayAnimatedImage` is explicitly set to false to prevent automatic animation on viewport entry
143143+- **Android**: Custom `AppCompatImageViewExtended` manages `Animatable` drawable state
144144+- **Web**: Uses HTMLMediaElement play/pause APIs
145145+146146+## Files Overview
147147+148148+```
149149+expo-bluesky-gif-view/
150150+├── index.ts # Module entry point
151151+├── expo-module.config.json # Expo module configuration
152152+├── src/
153153+│ ├── GifView.types.ts # TypeScript type definitions
154154+│ ├── GifView.tsx # Native implementation (iOS/Android)
155155+│ └── GifView.web.tsx # Web implementation
156156+├── ios/
157157+│ ├── ExpoBlueskyGifView.podspec # CocoaPods spec
158158+│ ├── ExpoBlueskyGifViewModule.swift # Module and prop definitions
159159+│ ├── GifView.swift # iOS view implementation
160160+│ └── Util.swift # Cache configuration
161161+└── android/
162162+ ├── build.gradle # Gradle build configuration
163163+ └── src/main/java/expo/modules/blueskygifview/
164164+ ├── ExpoBlueskyGifViewModule.kt # Module and prop definitions
165165+ ├── GifView.kt # Android view implementation
166166+ └── AppCompatImageViewExtended.kt # Custom ImageView for playback
167167+```
+231
modules/expo-bluesky-swiss-army/README.md
···11+# expo-bluesky-swiss-army
22+33+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.
44+55+## Overview
66+77+This module consolidates several native features into a single Expo module:
88+99+- **PlatformInfo**: Platform-specific accessibility and audio session management
1010+- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
1111+- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
1212+- **VisibilityView**: A native view component that tracks which view is currently visible on screen
1313+1414+## Modules
1515+1616+### PlatformInfo
1717+1818+Provides platform-specific information and audio session control.
1919+2020+**Functions:**
2121+2222+- `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).
2323+2424+- `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.
2525+2626+- `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.
2727+2828+**Platform Support:**
2929+- iOS: Full support for all functions
3030+- Android: `getIsReducedMotionEnabled()` only
3131+- Web: `getIsReducedMotionEnabled()` only
3232+3333+### Referrer
3434+3535+Tracks how users arrive at the app from external sources.
3636+3737+**Functions:**
3838+3939+- `getReferrerInfo(): ReferrerInfo | null` - Returns information about the source that launched the app. Returns `{referrer: string, hostname: string}` or `null`.
4040+ - **iOS**: Reads from SharedPrefs (set by app extensions or deep link handlers)
4141+ - **Android**: Extracts referrer from Intent extras or activity referrer
4242+ - **Web**: Parses `document.referrer` (excludes bsky.app domain)
4343+4444+- `getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo>` - Android only. Retrieves Google Play install referrer information including install timestamp and click timestamp. Uses the Google Play Install Referrer API.
4545+4646+**Platform Support:**
4747+- iOS: `getReferrerInfo()` only (reads from SharedPrefs)
4848+- Android: Both functions
4949+- Web: `getReferrerInfo()` only
5050+5151+### SharedPrefs
5252+5353+Native key-value storage that persists across app restarts. Uses iOS App Groups (`group.app.bsky`) for sharing data with extensions, and Android SharedPreferences.
5454+5555+**Functions:**
5656+5757+- `setValue(key: string, value: string | number | boolean | null | undefined): void` - Store a value
5858+- `removeValue(key: string): void` - Remove a value
5959+- `getString(key: string): string | undefined` - Get a string value
6060+- `getNumber(key: string): number | undefined` - Get a number value
6161+- `getBool(key: string): boolean | undefined` - Get a boolean value
6262+- `addToSet(key: string, value: string): void` - Add a value to a set
6363+- `removeFromSet(key: string, value: string): void` - Remove a value from a set
6464+- `setContains(key: string, value: string): boolean` - Check if a set contains a value
6565+6666+**Default Values (Android only):**
6767+The Android implementation initializes certain keys with default values on first access:
6868+- `playSoundChat`: true
6969+- `playSoundFollow`: false
7070+- `playSoundLike`: false
7171+- `playSoundMention`: false
7272+- `playSoundQuote`: false
7373+- `playSoundReply`: false
7474+- `playSoundRepost`: false
7575+- `badgeCount`: 0
7676+7777+**Platform Support:**
7878+- iOS: Full support (uses UserDefaults with App Group)
7979+- Android: Full support (uses SharedPreferences)
8080+- Web: Not implemented
8181+8282+**Implementation Notes:**
8383+- iOS uses App Group suite `group.app.bsky` to share preferences with app extensions
8484+- Android stores preferences in `xyz.blueskyweb.app`
8585+- Both platforms work around a bug where `JavaScriptValue.isString()` can cause crashes, so there's a separate `setString` function internally
8686+8787+### VisibilityView
8888+8989+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.
9090+9191+**Component:**
9292+9393+```tsx
9494+<VisibilityView
9595+ enabled={boolean}
9696+ onChangeStatus={(isActive: boolean) => void}
9797+>
9898+ {children}
9999+</VisibilityView>
100100+```
101101+102102+**Props:**
103103+- `enabled: boolean` - Whether this view participates in visibility tracking
104104+- `onChangeStatus: (isActive: boolean) => void` - Callback fired when the view becomes active or inactive
105105+- `children: React.ReactNode` - Child components
106106+107107+**Functions:**
108108+109109+- `updateActiveViewAsync(): Promise<void>` - Manually trigger recalculation of the active view
110110+111111+**How It Works:**
112112+113113+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":
114114+115115+1. A view must be at least 50% visible on screen
116116+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)
117117+3. Only one view can be active at a time - when a new view becomes active, the previous one is deactivated
118118+119119+This is useful for features like video autoplay, where you want to know which video is currently the "primary" one the user is viewing.
120120+121121+**Platform Support:**
122122+- iOS: Full support using UIView position tracking
123123+- Android: Full support using View position tracking
124124+- Web: Passthrough component (renders children without tracking)
125125+126126+## Architecture
127127+128128+### TypeScript Layer
129129+130130+The module uses platform-specific file extensions to provide appropriate implementations:
131131+132132+- `index.ts` - Throws NotImplementedError (base/fallback)
133133+- `index.native.ts` - Calls native modules via Expo Modules Core
134134+- `index.web.ts` - Web-specific implementations or stubs
135135+- `index.ios.ts` / `index.android.ts` - Platform-specific implementations when behavior differs
136136+137137+### Native Layer
138138+139139+**iOS:**
140140+- Swift implementation using Expo Modules Core
141141+- Files organized by feature in subdirectories (PlatformInfo/, Referrer/, SharedPrefs/, Visibility/)
142142+- Uses standard iOS APIs: UIAccessibility, AVAudioSession, UserDefaults, UIView
143143+144144+**Android:**
145145+- Kotlin implementation using Expo Modules Core
146146+- Package structure: `expo.modules.blueskyswissarmy.[feature]`
147147+- Uses standard Android APIs: Settings.Global, InstallReferrerClient, SharedPreferences, View
148148+149149+## Key Files
150150+151151+### TypeScript
152152+- `index.ts` - Main module exports
153153+- `src/NotImplemented.ts` - Error thrown when functionality is not available on current platform
154154+- `src/[Feature]/types.ts` - TypeScript type definitions for each feature
155155+- `src/[Feature]/index.*.ts` - Platform-specific implementations
156156+157157+### iOS
158158+- `ios/ExpoBlueskySwissArmy.podspec` - CocoaPods specification
159159+- `ios/[Feature]/Expo*Module.swift` - Expo module definitions
160160+- `ios/SharedPrefs/SharedPrefs.swift` - Shared preference manager (usable from other native code)
161161+- `ios/Visibility/VisibilityViewManager.swift` - Global view tracking manager
162162+163163+### Android
164164+- `android/build.gradle` - Gradle build configuration (includes installreferrer dependency)
165165+- `android/src/main/java/expo/modules/blueskyswissarmy/[feature]/Expo*Module.kt` - Expo module definitions
166166+- `android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt` - Shared preference manager
167167+- `android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt` - Global view tracking manager
168168+169169+## Configuration
170170+171171+### Expo Module Config
172172+173173+The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android.
174174+175175+### iOS
176176+177177+Requires iOS 13.4 or later. Uses the App Group `group.app.bsky` for SharedPrefs - ensure this is configured in your app's entitlements.
178178+179179+### Android
180180+181181+- Minimum SDK: 21
182182+- Target SDK: 34
183183+- Requires `com.android.installreferrer:installreferrer:2.2` dependency for Google Play referrer tracking
184184+185185+## Usage Example
186186+187187+```typescript
188188+import {
189189+ PlatformInfo,
190190+ AudioCategory,
191191+ Referrer,
192192+ SharedPrefs,
193193+ VisibilityView
194194+} from 'expo-bluesky-swiss-army'
195195+196196+// Check for reduced motion
197197+const isReducedMotion = PlatformInfo.getIsReducedMotionEnabled()
198198+199199+// Set audio category for video playback (iOS)
200200+PlatformInfo.setAudioCategory(AudioCategory.Playback)
201201+PlatformInfo.setAudioActive(true)
202202+203203+// Check how user arrived at the app
204204+const referrer = Referrer.getReferrerInfo()
205205+if (referrer) {
206206+ console.log('User came from:', referrer.hostname)
207207+}
208208+209209+// Store a preference
210210+SharedPrefs.setValue('lastOpenedAt', Date.now())
211211+SharedPrefs.setValue('hasSeenOnboarding', true)
212212+213213+// Track visible view
214214+<VisibilityView
215215+ enabled={true}
216216+ onChangeStatus={(isActive) => {
217217+ if (isActive) {
218218+ // This view is now the primary visible view
219219+ video.play()
220220+ } else {
221221+ video.pause()
222222+ }
223223+ }}
224224+>
225225+ <VideoPlayer />
226226+</VisibilityView>
227227+```
228228+229229+## Version
230230+231231+Current version: 0.6.0
+113-1
modules/expo-emoji-picker/README.md
···11# expo-emoji-picker
2233-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)
33+A native emoji picker module for React Native applications built with Expo. This module provides platform-specific emoji selection interfaces using native system components.
44+55+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).
66+77+## What It Does
88+99+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.
1010+1111+## Platform Support
1212+1313+- **iOS**: Uses [MCEmojiPicker](https://github.com/izyumkin/MCEmojiPicker) presented as a modal picker
1414+- **Android**: Uses the system `androidx.emoji2.emojipicker.EmojiPickerView` component
1515+- **Web**: Not supported (native platforms only)
1616+1717+## How It Works
1818+1919+### Architecture
2020+2121+The module follows Expo's module architecture with three layers:
2222+2323+1. **JavaScript/TypeScript Layer** (`src/`): React components and type definitions
2424+2. **Native iOS Layer** (`ios/`): Swift implementation using MCEmojiPicker
2525+3. **Native Android Layer** (`android/`): Kotlin implementation using AndroidX emoji picker
2626+2727+### iOS Implementation
2828+2929+On iOS, the module creates an invisible tap target view. When tapped, it presents MCEmojiPicker as a modal view controller:
3030+3131+- `EmojiPickerView.swift`: Custom view that handles tap gestures and presents the picker
3232+- `EmojiPickerModule.swift`: Module definition that registers the view with Expo
3333+- Uses MCEmojiPicker dependency for the native picker UI
3434+3535+The picker is presented from the current React view controller and returns the selected emoji via an event dispatcher.
3636+3737+### Android Implementation
3838+3939+On Android, the module embeds the AndroidX EmojiPickerView directly as a full-screen component:
4040+4141+- `EmojiPickerModuleView.kt`: Wraps the system EmojiPickerView in an ExpoView
4242+- `EmojiPickerModule.kt`: Module definition that registers the view with Expo
4343+- Handles configuration changes (dark mode, orientation) by recreating the view
4444+4545+The AndroidX emoji picker provides a grid-based interface with category tabs and search.
4646+4747+### Platform-Specific React Components
4848+4949+The module uses platform-specific file extensions for different behaviors:
5050+5151+- `EmojiPicker.tsx` (iOS): Renders an invisible tap target that accepts children
5252+- `EmojiPicker.android.tsx` (Android): Renders the full emoji picker view with flex: 1 layout
5353+5454+Both components normalize the native event structure to provide a consistent `onEmojiSelected` callback.
5555+5656+## Key Files
5757+5858+### Configuration
5959+- `expo-module.config.json`: Defines the module name and native class mappings for iOS and Android
6060+6161+### TypeScript/React
6262+- `index.ts`: Public exports for the module
6363+- `src/EmojiPickerModule.ts`: Native module registration
6464+- `src/EmojiPickerModule.types.ts`: TypeScript type definitions
6565+- `src/EmojiPickerView.tsx`: Base native view component
6666+- `src/EmojiPicker.tsx`: iOS-specific implementation
6767+- `src/EmojiPicker.android.tsx`: Android-specific implementation
6868+6969+### iOS (Swift)
7070+- `ios/EmojiPickerModule.swift`: Module definition (11 lines)
7171+- `ios/EmojiPickerView.swift`: View implementation with tap handling and picker presentation
7272+- `ios/EmojiPickerModule.podspec`: CocoaPods specification with MCEmojiPicker dependency
7373+7474+### Android (Kotlin)
7575+- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModule.kt`: Module definition
7676+- `android/src/main/java/expo/community/modules/emojipicker/EmojiPickerModuleView.kt`: View implementation
7777+- `android/build.gradle`: Gradle configuration with androidx.emoji2:emoji2-emojipicker dependency
7878+7979+## Usage
8080+8181+```tsx
8282+import { EmojiPicker } from 'expo-emoji-picker'
8383+8484+function MyComponent() {
8585+ const handleEmojiSelected = (emoji: string) => {
8686+ console.log('Selected emoji:', emoji)
8787+ }
8888+8989+ return (
9090+ <EmojiPicker onEmojiSelected={handleEmojiSelected}>
9191+ {/* On iOS, children render as the tap target */}
9292+ {/* On Android, children are ignored - picker is shown directly */}
9393+ </EmojiPicker>
9494+ )
9595+}
9696+```
9797+9898+## Dependencies
9999+100100+### iOS
101101+- ExpoModulesCore
102102+- MCEmojiPicker (external CocoaPods dependency)
103103+- Minimum iOS version: 15.1
104104+105105+### Android
106106+- expo-modules-core
107107+- androidx.emoji2:emoji2-emojipicker:1.5.0
108108+- Minimum SDK: 21
109109+- Target SDK: 34
110110+111111+## Configuration
112112+113113+No additional configuration is required. The module is automatically linked through Expo's autolinking system when the app is built.
114114+115115+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
modules/expo-receive-android-intents/README.md
···11# Expo Receive Android Intents
2233-This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
44-The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
55-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
66-platforms.
33+An Expo module that handles incoming Android intents for sharing text, images, and videos into the Bluesky app.
44+55+## What It Does
66+77+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:
88+99+- **Text sharing** - Share plain text to compose a post
1010+- **Image sharing** - Share single or multiple images (up to 4) to attach to a post
1111+- **Video sharing** - Share a single video to attach to a post
1212+1313+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.
1414+1515+## Platform Support
1616+1717+- **Android**: Fully supported
1818+- **iOS**: No-op (iOS handles share intents differently)
1919+- **Web**: No-op
2020+2121+## How It Works
2222+2323+### Architecture
2424+2525+The module uses Expo's module lifecycle hooks to intercept Android intents at two key moments:
2626+2727+1. **OnCreate** - When the app is first launched from an intent
2828+2. **OnNewIntent** - When the app receives a new intent while already running
2929+3030+### Intent Processing Flow
3131+3232+1. **Intent Reception**: Android sends an `ACTION_SEND` or `ACTION_SEND_MULTIPLE` intent
3333+2. **Type Detection**: Module determines content type (text, image, or video)
3434+3. **Content Processing**:
3535+ - **Text**: URL-encodes the text
3636+ - **Images**: Saves to app cache, extracts dimensions (limited to 4 images max)
3737+ - **Video**: Copies to app cache with extension detection, extracts dimensions
3838+4. **Deep Link Generation**: Creates a `bluesky://intent/compose` URL with encoded parameters
3939+5. **App Launch**: Starts a new activity with the deep link, which is handled by `useIntentHandler`
4040+4141+### Deep Link Format
4242+4343+The module generates deep links in the following formats:
4444+4545+```
4646+# Text only
4747+bluesky://intent/compose?text=<encoded-text>
4848+4949+# Images (single or multiple)
5050+bluesky://intent/compose?imageUris=<uri1>|<width>|<height>,<uri2>|<width>|<height>&text=<encoded-text>
5151+5252+# Video (single only)
5353+bluesky://intent/compose?videoUri=<uri>|<width>|<height>&text=<encoded-text>
5454+```
5555+5656+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.
5757+5858+### Security Considerations
5959+6060+- Images and videos are copied to the app's private cache directory before being passed to the app
6161+- The JavaScript handler (`useIntentHandler.ts`) validates image URIs with a regex to prevent external URLs
6262+- Image URIs containing `http://` or `https://` are filtered out
6363+- Multiple image sharing is limited to 4 images maximum
6464+6565+## Key Files
6666+6767+### Module Configuration
6868+6969+- **expo-module.config.json** - Declares the module and registers it with Expo (Android-only)
7070+7171+### Native Implementation
7272+7373+- **ExpoReceiveAndroidIntentsModule.kt** - Main module class with intent handling logic
7474+ - `handleIntent()` - Routes intents based on type
7575+ - `handleTextIntent()` - Processes text sharing
7676+ - `handleAttachmentIntent()` - Processes single image/video
7777+ - `handleAttachmentsIntent()` - Processes multiple images
7878+ - `getImageInfo()` - Saves images to cache and extracts dimensions
7979+ - `getVideoInfo()` - Extracts video dimensions using MediaMetadataRetriever
8080+8181+- **android/build.gradle** - Gradle build configuration
8282+ - Version: 0.4.1
8383+ - Requires: Kotlin, expo-modules-core
8484+ - Compile SDK: 33, Min SDK: 21, Target SDK: 34
8585+8686+- **android/src/main/AndroidManifest.xml** - Empty manifest (intent filters configured in main app)
8787+8888+### JavaScript Integration
8989+9090+The deep links generated by this module are handled by:
9191+9292+- **src/lib/hooks/useIntentHandler.ts** - `useComposeIntent()` parses the deep link parameters and opens the composer with pre-populated content
9393+9494+## Installation
9595+9696+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.
9797+9898+## Configuration
9999+100100+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.
101101+102102+## Implementation Notes
103103+104104+### Android Version Compatibility
710588-No installation is required. Gradle will automatically add this module on build.
106106+The module uses version-specific APIs for Android 13+ (API 33):
107107+- `getParcelableExtra()` with type parameter on Android 13+
108108+- Legacy `getParcelableExtra()` on older versions
109109+110110+### File Handling
111111+112112+- Temporary files are created using `File.createTempFile()` in the app's cache directory
113113+- Image files use `.jpeg` extension and are compressed at 100% quality
114114+- Video files preserve their original extension, defaulting to `.mp4` if none is detected
115115+116116+### Limitations
117117+118118+- Video sharing only supports a single video
119119+- Multiple video sharing is not implemented
120120+- Images are always converted to JPEG format
121121+- Maximum of 4 images can be shared at once
+116
modules/expo-scroll-forwarder/README.md
···11+# expo-scroll-forwarder
22+33+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.
44+55+## What It Does
66+77+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.
88+99+Key behaviors:
1010+- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
1111+- Implements physics-based deceleration animations that match native scroll behavior
1212+- Supports pull-to-refresh interactions with haptic feedback
1313+- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
1414+- Provides rubber-band damping when scrolling past content bounds
1515+1616+## Architecture
1717+1818+The module consists of three main parts:
1919+2020+### 1. Native iOS Implementation (Swift)
2121+2222+**ExpoScrollForwarderView.swift** - The core native view component that:
2323+- Attaches a UIPanGestureRecognizer to intercept scroll gestures
2424+- Finds and references the target RCTScrollView using its React Native tag
2525+- Implements custom scroll physics including velocity-based decay animation
2626+- Manages gesture recognizer delegation to prevent conflicts with system gestures
2727+- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
2828+2929+**ExpoScrollForwarderModule.swift** - The Expo module definition that:
3030+- Registers the view component with Expo
3131+- Exposes the `scrollViewTag` prop to specify which scroll view to control
3232+3333+### 2. TypeScript Interface
3434+3535+**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
3636+- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
3737+- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
3838+3939+**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
4040+- `scrollViewTag`: The React Native tag of the scroll view to control
4141+- `children`: The content to render (typically a header component)
4242+4343+### 3. Module Configuration
4444+4545+**expo-module.config.json** - Declares iOS-only platform support
4646+4747+**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
4848+4949+## Usage
5050+5151+```tsx
5252+import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
5353+5454+function ProfileScreen() {
5555+ const scrollViewTag = useRef(null)
5656+5757+ return (
5858+ <View>
5959+ <ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
6060+ <ProfileHeader />
6161+ </ExpoScrollForwarderView>
6262+6363+ <ScrollView ref={scrollViewTag}>
6464+ {/* Scrollable content */}
6565+ </ScrollView>
6666+ </View>
6767+ )
6868+}
6969+```
7070+7171+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.
7272+7373+## Platform Support
7474+7575+- **iOS**: Full native implementation with custom scroll physics
7676+- **Android**: No-op wrapper (renders children without scroll forwarding)
7777+- **Web**: No-op wrapper (renders children without scroll forwarding)
7878+7979+The module is designed to enhance iOS UX while gracefully degrading on other platforms.
8080+8181+## Key Implementation Details
8282+8383+### Gesture Recognition
8484+- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
8585+- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
8686+- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
8787+8888+### Scroll Physics
8989+- Implements custom decay animation at 120fps using a Timer
9090+- Velocity decay factor: 0.9875 per frame
9191+- Velocity clamped to +/- 5000 points/second
9292+- Rubber-band damping: offsets below 0 are reduced by 55%
9393+- Animation stops when velocity drops below 5 points/second
9494+9595+### Pull-to-Refresh
9696+- Triggers at -130pt scroll offset
9797+- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
9898+- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
9999+100100+### Scroll View Management
101101+- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
102102+- Properly cleans up gesture recognizers when switching between scroll views
103103+- Maintains references to both the scroll view and its refresh control
104104+105105+## Files Overview
106106+107107+| File | Purpose |
108108+|------|---------|
109109+| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
110110+| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
111111+| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
112112+| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
113113+| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
114114+| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
115115+| `index.ts` | Module entry point |
116116+| `expo-module.config.json` | Expo module configuration |