Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c631709c7 | |||
| 38c8adcc27 | |||
| dcc06a90a0 | |||
| c7e9efbf99 | |||
| 1c38665d4c | |||
| 5d40532aa9 | |||
| abe0ca521d | |||
| 07344f70fc | |||
| bfdaab0a14 | |||
| 3153ea4302 | |||
| 6d53459e92 | |||
| 2ab1e2c9e9 | |||
| 0df1d6f53e | |||
| 18052f08e0 | |||
| 3931b90818 | |||
| 985129dd34 | |||
| 8c2e4c6fad | |||
| d58ff89441 | |||
| 014ffac903 | |||
| ae0c2e8697 | |||
| b8cabfaae6 | |||
| 444c5787c5 | |||
| 2798e98c9c | |||
| 9778a3da16 | |||
| 52b8201d2f | |||
| dddc022747 | |||
| 5df51bdea7 | |||
| bc3672ceeb | |||
| 35411e88c9 | |||
| 935347c73d | |||
| feebc6a98b | |||
| 587dc8dfe8 | |||
| 43108533eb | |||
| adca192f3a | |||
| 591504307d | |||
| bc9ad2c2d9 | |||
| 9e9ff70682 | |||
| 226a321a27 | |||
| e58feaeb0f | |||
| a77b6e3525 | |||
| a97b15b204 | |||
| 8f56fca82c | |||
| 524cbc514d | |||
| 3358e1947b | |||
| 6e3c9c3a9f | |||
| ac68cfe98c | |||
| 36c95d7dc6 | |||
| 9f3c21e298 | |||
| e10c05d735 | |||
| a9e170b6d0 | |||
| f51602b3fe | |||
| cc8f22887f | |||
| cc861093c2 | |||
| 8c5899fc93 | |||
| 6d8b4a2070 | |||
| 5fb3af71c6 |
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseStarterPackUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {messages} from '#/locale/locales/en/messages'
|
||||
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
|
||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
@@ -450,6 +451,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 +853,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', () => {
|
||||
@@ -1049,3 +1086,31 @@ describe('tenorUrlToBskyGifUrl', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('klipyUrlToBskyGifUrl', () => {
|
||||
const inputs = [
|
||||
'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 k.gifs.bsky.app as hostname for input url',
|
||||
input => {
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" 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-5Zm-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-1H7Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 446 B |
+7
-3
@@ -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', () => ({
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.121.0",
|
||||
"version": "1.122.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -81,16 +81,19 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.9",
|
||||
"@atproto/api": "^0.19.11",
|
||||
"@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/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.2",
|
||||
"@bsky.app/tapper": "^0.5.0",
|
||||
"@bsky.app/sift": "^0.3.3",
|
||||
"@bsky.app/tapper": "^0.5.1",
|
||||
"@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",
|
||||
|
||||
@@ -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() {
|
||||
@@ -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 {
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,6 +2,8 @@ import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {IS_DEV, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
@@ -24,6 +26,20 @@ export function PassiveAnalytics() {
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (IS_DEV || IS_TESTFLIGHT) {
|
||||
const feats = Object.values(Features).reduce(
|
||||
(acc, feat) => {
|
||||
acc[feat] = features.evalFeature(feat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<Features, any>,
|
||||
)
|
||||
ax.logger.info('FEATURES', {
|
||||
features: feats,
|
||||
definitions: features.getFeatures(),
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
@@ -2,18 +2,19 @@ import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
||||
import * as env from '#/env'
|
||||
|
||||
export {Features} from '#/analytics/features/types'
|
||||
|
||||
const logger = Logger.create(Logger.Context.Growthbook)
|
||||
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)
|
||||
@@ -27,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
|
||||
|
||||
@@ -44,7 +45,13 @@ export const features = new GrowthBook({
|
||||
* initialization completes.
|
||||
*/
|
||||
export const init = new Promise<void>(async y => {
|
||||
await features.init({timeout: TIMEOUT_INIT})
|
||||
const res = await features.init({timeout: TIMEOUT_INIT})
|
||||
if (!res.success) {
|
||||
logger.warn('GrowthBook initialization failed or timed out', {
|
||||
source: res.source,
|
||||
safeMessage: res.error?.toString(),
|
||||
})
|
||||
}
|
||||
y()
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -487,6 +487,7 @@ export type Events = {
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -514,6 +515,7 @@ export type Events = {
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -798,6 +800,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
|
||||
|
||||
@@ -58,7 +58,17 @@ export function Autocomplete({
|
||||
data={data}
|
||||
onSelect={onSelect}
|
||||
onDismiss={onDismiss}
|
||||
style={[
|
||||
outerStyle={[
|
||||
a.rounded_md,
|
||||
a.w_full,
|
||||
t.atoms.shadow_lg,
|
||||
IS_WEB
|
||||
? {
|
||||
maxWidth: 300,
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
innerStyle={[
|
||||
a.overflow_hidden,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
@@ -18,7 +19,7 @@ import type * as bsky from '#/types/bsky'
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
size?: 'small' | 'medium' | 'large' | number
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
@@ -27,10 +28,32 @@ export function AvatarBubbles({
|
||||
size = 'large',
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
|
||||
const scale = 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 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 =
|
||||
(typeof size === 'number' && size < 120) ||
|
||||
size === 'small' ||
|
||||
size === 'medium'
|
||||
? -2
|
||||
: 0
|
||||
|
||||
const initialValue = animate ? 0 : 1
|
||||
const p0 = useSharedValue(initialValue)
|
||||
@@ -66,7 +89,7 @@ export function AvatarBubbles({
|
||||
let avatars = (
|
||||
<>
|
||||
<AvatarBubble
|
||||
profile={profiles[0] ?? allProfiles[0]}
|
||||
profile={profiles[0]}
|
||||
scale={p0}
|
||||
size={76}
|
||||
x={-2}
|
||||
@@ -177,7 +200,7 @@ function AvatarBubble({
|
||||
includeProfileBorder,
|
||||
}: {
|
||||
profile?: bsky.profile.AnyProfileView
|
||||
scale: Animated.SharedValue<number>
|
||||
scale: SharedValue<number>
|
||||
size: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
x: number
|
||||
@@ -200,7 +223,6 @@ 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,
|
||||
|
||||
@@ -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,
|
||||
@@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
export function Trigger({
|
||||
children,
|
||||
label,
|
||||
contentLabel,
|
||||
style,
|
||||
onTap,
|
||||
}: TriggerProps) {
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
}
|
||||
}, [context, insets])
|
||||
|
||||
const tapGesture = useMemo(() => {
|
||||
const gesture = Gesture.Tap()
|
||||
.numberOfTaps(1)
|
||||
.cancelsTouchesInView(false)
|
||||
.runOnJS(true)
|
||||
if (onTap) {
|
||||
gesture.onEnd(() => void onTap())
|
||||
}
|
||||
return gesture
|
||||
}, [onTap])
|
||||
|
||||
const doubleTapGesture = useMemo(() => {
|
||||
return Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
@@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
})
|
||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||
|
||||
// Order matters here: doubleTapGesture must come before tapGesture.
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
tapGesture,
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
@@ -508,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
|
||||
@@ -636,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
|
||||
|
||||
@@ -744,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
|
||||
|
||||
@@ -760,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,
|
||||
@@ -777,7 +798,16 @@ export function Item({
|
||||
},
|
||||
)
|
||||
},
|
||||
[id, measurement, registerHoverable, close, onPress, align, position],
|
||||
[
|
||||
id,
|
||||
measurement,
|
||||
registerHoverable,
|
||||
close,
|
||||
onPress,
|
||||
align,
|
||||
menuXOffset,
|
||||
position,
|
||||
],
|
||||
)
|
||||
|
||||
const itemContext = useMemo(
|
||||
|
||||
@@ -65,6 +65,7 @@ export type ContextType = {
|
||||
|
||||
export type MenuContextType = {
|
||||
align: 'left' | 'right'
|
||||
xOffset: number
|
||||
}
|
||||
|
||||
export type ItemContextType = {
|
||||
@@ -84,6 +85,14 @@ export type TriggerProps = {
|
||||
hint?: string
|
||||
role?: AccessibilityRole
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Callback for single taps. Composed with the double-tap and
|
||||
* press-and-hold gestures via `Gesture.Exclusive`, so a double tap
|
||||
* does not also fire this handler.
|
||||
*
|
||||
* @platform ios, android
|
||||
*/
|
||||
onTap?: () => void
|
||||
}
|
||||
export type TriggerChildProps =
|
||||
| {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: false,
|
||||
isHeightConstrained: false,
|
||||
})
|
||||
Context.displayName = 'DialogContext'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+8
-2
@@ -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 () => {
|
||||
@@ -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 (1–6), 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
recId={data?.recId}
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -141,18 +141,18 @@ export function useLink({
|
||||
})
|
||||
} else {
|
||||
if (isExternal) {
|
||||
openLink(href, overridePresentation, shouldProxy)
|
||||
void openLink(href, overridePresentation, shouldProxy)
|
||||
} else {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
shareUrl(BSKY_DOWNLOAD_URL)
|
||||
void shareUrl(BSKY_DOWNLOAD_URL)
|
||||
} else if (
|
||||
shouldOpenInNewTab ||
|
||||
href.startsWith('http') ||
|
||||
href.startsWith('mailto')
|
||||
) {
|
||||
openLink(href)
|
||||
void openLink(href)
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
@@ -232,7 +232,7 @@ export function useLink({
|
||||
share: true,
|
||||
})
|
||||
} else {
|
||||
shareUrl(href)
|
||||
void shareUrl(href)
|
||||
}
|
||||
}, [
|
||||
disableMismatchWarning,
|
||||
@@ -451,7 +451,7 @@ export function SimpleInlineLinkText({
|
||||
const onPress = (e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
if (exitEarlyIfFalse === false) return
|
||||
Linking.openURL(href)
|
||||
void Linking.openURL(href)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {isGifEmbed} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -38,7 +38,7 @@ export function Embed({
|
||||
)
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isTenorGifUri(e.view.external.uri)) return null
|
||||
if (!isGifEmbed(e.view.external.uri)) return null
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<GifItem
|
||||
|
||||
@@ -59,7 +59,10 @@ export const ExternalEmbed = ({
|
||||
}
|
||||
}, [link.uri, playHaptic])
|
||||
|
||||
if (embedPlayerParams?.source === 'tenor') {
|
||||
if (
|
||||
embedPlayerParams?.source === 'tenor' ||
|
||||
embedPlayerParams?.source === 'klipy'
|
||||
) {
|
||||
const parsedAlt = parseAltFromGIFDescription(link.description)
|
||||
return (
|
||||
<View style={style}>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import {InteractionManager, View} from 'react-native'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
} from 'react-native-reanimated'
|
||||
import {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {useLightboxControls} from '#/state/lightbox'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
@@ -37,34 +31,22 @@ export function ImageEmbed({
|
||||
alt: img.alt,
|
||||
dimensions: img.aspectRatio ?? null,
|
||||
}))
|
||||
const _openLightbox = (
|
||||
index: number,
|
||||
thumbRects: (MeasuredDimensions | null)[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
openLightbox({
|
||||
images: items.map((item, i) => ({
|
||||
...item,
|
||||
thumbRect: thumbRects[i] ?? null,
|
||||
thumbDimensions: fetchedDims[i] ?? null,
|
||||
type: 'image',
|
||||
})),
|
||||
index,
|
||||
})
|
||||
}
|
||||
const onPress = (
|
||||
index: number,
|
||||
refs: AnimatedRef<any>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rects: (MeasuredDimensions | null)[] = []
|
||||
for (const r of refs) {
|
||||
rects.push(measure(r))
|
||||
}
|
||||
runOnJS(_openLightbox)(index, rects, fetchedDims)
|
||||
})()
|
||||
openLightbox({
|
||||
images: items.map((item, i) => ({
|
||||
...item,
|
||||
thumbRect: null,
|
||||
thumbRef: refs[i] ?? null,
|
||||
thumbDimensions: fetchedDims[i] ?? null,
|
||||
thumbBorderRadius: tokens.borderRadius.md,
|
||||
type: 'image',
|
||||
})),
|
||||
index,
|
||||
})
|
||||
}
|
||||
const onPressIn = (_: number) => {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useImperativeHandle, useRef, useState} from 'react'
|
||||
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
||||
import {BlueskyVideoView} from '@bsky.app/video'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
|
||||
@@ -39,12 +39,11 @@ import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
|
||||
import {
|
||||
type CommonProps,
|
||||
type EmbedProps,
|
||||
PostEmbedViewContext,
|
||||
QuoteEmbedViewContext,
|
||||
type PostEmbedViewContext,
|
||||
} from './types'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
|
||||
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||
export {PostEmbedViewContext} from './types'
|
||||
|
||||
export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
||||
const embed = parseEmbed(rawEmbed)
|
||||
@@ -164,11 +163,7 @@ function RecordEmbed({
|
||||
<QuoteEmbed
|
||||
{...rest}
|
||||
embed={embed}
|
||||
viewContext={
|
||||
rest.viewContext === PostEmbedViewContext.Feed
|
||||
? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
: undefined
|
||||
}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
allowNestedQuotes={rest.allowNestedQuotes}
|
||||
/>
|
||||
@@ -229,9 +224,10 @@ export function QuoteEmbed({
|
||||
linkDisabled,
|
||||
isWithinQuote: parentIsWithinQuote,
|
||||
allowNestedQuotes: parentAllowNestedQuotes,
|
||||
viewContext,
|
||||
}: Omit<CommonProps, 'viewContext'> & {
|
||||
embed: EmbedType<'post'>
|
||||
viewContext?: QuoteEmbedViewContext
|
||||
viewContext?: PostEmbedViewContext
|
||||
linkDisabled?: boolean
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -309,7 +305,7 @@ export function QuoteEmbed({
|
||||
<Embed
|
||||
embed={quote.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
|
||||
viewContext={viewContext}
|
||||
isWithinQuote={parentIsWithinQuote ?? true}
|
||||
// already within quote? override nested
|
||||
allowNestedQuotes={
|
||||
|
||||
@@ -5,10 +5,7 @@ export enum PostEmbedViewContext {
|
||||
ThreadHighlighted = 'ThreadHighlighted',
|
||||
Feed = 'Feed',
|
||||
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
|
||||
}
|
||||
|
||||
export enum QuoteEmbedViewContext {
|
||||
FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
|
||||
ChatMessage = 'ChatMessage',
|
||||
}
|
||||
|
||||
export type CommonProps = {
|
||||
|
||||
@@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function RecentChats({
|
||||
postUri,
|
||||
@@ -60,23 +60,24 @@ export function RecentChats({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
nestedScrollEnabled>
|
||||
{convos && convos.length > 0 ? (
|
||||
convos.map(convo => {
|
||||
const otherMember = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
convos.map(c => {
|
||||
const convo = parseConvoView(c, currentAccount?.did)
|
||||
|
||||
if (!convo) return null
|
||||
|
||||
if (
|
||||
!otherMember ||
|
||||
otherMember.handle === 'missing.invalid' ||
|
||||
convo.muted
|
||||
)
|
||||
(convo.kind === 'direct' &&
|
||||
convo.primaryMember.handle === 'missing.invalid') ||
|
||||
convo.view.muted
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RecentChatItem
|
||||
key={convo.id}
|
||||
profile={otherMember}
|
||||
onPress={() => onSelectChat(convo.id)}
|
||||
key={convo.view.id}
|
||||
convo={convo}
|
||||
onPress={() => onSelectChat(convo.view.id)}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
@@ -99,26 +100,33 @@ export function RecentChats({
|
||||
const WIDTH = 80
|
||||
|
||||
function RecentChatItem({
|
||||
profile: profileUnshadowed,
|
||||
onPress,
|
||||
moderationOpts,
|
||||
convo,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
onPress: () => void
|
||||
moderationOpts: ModerationOpts
|
||||
convo: ConvoWithDetails
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const primaryProfile = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const moderation = moderateProfile(primaryProfile, moderationOpts)
|
||||
const name =
|
||||
convo.kind === 'group'
|
||||
? convo.details.name
|
||||
: createSanitizedDisplayName(
|
||||
primaryProfile,
|
||||
true,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
if (isBlockedOrBlocking(profile) || isMuted(profile)) {
|
||||
if (
|
||||
convo.kind === 'direct' &&
|
||||
(isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -133,12 +141,16 @@ function RecentChatItem({
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={WIDTH - 8}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatar={primaryProfile.avatar}
|
||||
size={WIDTH - 8}
|
||||
type={primaryProfile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
)}
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}>
|
||||
<Text
|
||||
emoji
|
||||
@@ -146,7 +158,13 @@ function RecentChatItem({
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} />
|
||||
{convo.kind === 'direct' && (
|
||||
<ProfileBadges
|
||||
profile={primaryProfile}
|
||||
size="xs"
|
||||
style={[a.pl_2xs]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -249,6 +249,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
moderationOpts={moderationOpts!}
|
||||
noBorder={index === 0}
|
||||
position={index}
|
||||
recSource={hasSearchText ? 'Search' : undefined}
|
||||
recId={recIdForLogging}
|
||||
isGuide={isGuide}
|
||||
/>
|
||||
@@ -264,7 +265,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, recIdForLogging, isGuide],
|
||||
[moderationOpts, hasSearchText, recIdForLogging, isGuide],
|
||||
)
|
||||
|
||||
// Track seen profiles
|
||||
@@ -286,6 +287,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
|
||||
recSource: hasSearchText ? 'Search' : undefined,
|
||||
recId: recIdForLogging,
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
@@ -548,6 +550,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts,
|
||||
noBorder,
|
||||
position,
|
||||
recSource,
|
||||
recId,
|
||||
isGuide,
|
||||
}: {
|
||||
@@ -555,6 +558,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts: ModerationOpts
|
||||
noBorder?: boolean
|
||||
position: number
|
||||
recSource?: 'Search'
|
||||
recId?: string
|
||||
isGuide: boolean
|
||||
}): React.ReactNode => {
|
||||
@@ -564,6 +568,7 @@ let FollowProfileCard = ({
|
||||
moderationOpts={moderationOpts}
|
||||
noBorder={noBorder}
|
||||
position={position}
|
||||
recSource={recSource}
|
||||
recId={recId}
|
||||
isGuide={isGuide}
|
||||
/>
|
||||
@@ -577,6 +582,7 @@ function FollowProfileCardInner({
|
||||
onFollow,
|
||||
noBorder,
|
||||
position,
|
||||
recSource,
|
||||
recId,
|
||||
isGuide,
|
||||
}: {
|
||||
@@ -585,6 +591,7 @@ function FollowProfileCardInner({
|
||||
onFollow?: () => void
|
||||
noBorder?: boolean
|
||||
position: number
|
||||
recSource?: 'Search'
|
||||
recId?: string
|
||||
isGuide: boolean
|
||||
}) {
|
||||
@@ -625,6 +632,7 @@ function FollowProfileCardInner({
|
||||
? 'ProgressGuide'
|
||||
: 'SeeMoreSuggestedUsers',
|
||||
location: 'Card',
|
||||
recSource,
|
||||
recId,
|
||||
position,
|
||||
suggestedDid: profile.did,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {isAppPassword} from '#/lib/jwt'
|
||||
@@ -34,7 +32,7 @@ export function BirthDateSettingsDialog({
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -45,11 +43,11 @@ export function BirthDateSettingsDialog({
|
||||
<Dialog.Handle />
|
||||
{isBirthdateUpdateAllowed ? (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`My Birthdate`)}
|
||||
label={l`My birthdate`}
|
||||
style={web({maxWidth: 400})}>
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[a.text_xl, a.font_semi_bold]}>
|
||||
<Trans>My Birthdate</Trans>
|
||||
<Trans>My birthdate</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
@@ -64,9 +62,7 @@ export function BirthDateSettingsDialog({
|
||||
<ErrorMessage
|
||||
message={
|
||||
error?.toString() ||
|
||||
_(
|
||||
msg`We were unable to load your birthdate preferences. Please try again.`,
|
||||
)
|
||||
l`We were unable to load your birthdate preferences. Please try again.`
|
||||
}
|
||||
style={[a.rounded_sm]}
|
||||
/>
|
||||
@@ -88,7 +84,7 @@ export function BirthDateSettingsDialog({
|
||||
</Dialog.ScrollableInner>
|
||||
) : (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`You recently changed your birthdate`)}
|
||||
label={l`You recently changed your birthdate`}
|
||||
style={web({maxWidth: 400})}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text
|
||||
@@ -123,15 +119,16 @@ function BirthdayInner({
|
||||
control: Dialog.DialogControlProps
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const cleanError = useCleanError()
|
||||
const [date, setDate] = useState(preferences.birthDate || getDateAgo(18))
|
||||
const {isPending, error, mutateAsync: setBirthDate} = useBirthdateMutation()
|
||||
const hasChanged = date !== preferences.birthDate
|
||||
const errorMessage = useMemo(() => {
|
||||
if (error) {
|
||||
const {raw, clean} = cleanError(error)
|
||||
return clean || raw || error.toString()
|
||||
const e = error as Error
|
||||
const {raw, clean} = cleanError(e)
|
||||
return clean || raw || e.toString()
|
||||
}
|
||||
}, [error, cleanError])
|
||||
|
||||
@@ -146,7 +143,8 @@ function BirthdayInner({
|
||||
await setBirthDate({birthDate: date})
|
||||
}
|
||||
control.close()
|
||||
} catch (e: any) {
|
||||
} catch (error) {
|
||||
const e = error as Error
|
||||
logger.error(`setBirthDate failed`, {message: e.message})
|
||||
}
|
||||
}, [date, setBirthDate, control, hasChanged])
|
||||
@@ -158,11 +156,10 @@ function BirthdayInner({
|
||||
testID="birthdayInput"
|
||||
value={date}
|
||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||
label={_(msg`Birthdate`)}
|
||||
accessibilityHint={_(msg`Enter your birthdate`)}
|
||||
label={l`Birthdate`}
|
||||
accessibilityHint={l`Enter your birthdate`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isUnder18 && hasChanged && (
|
||||
<Admonition type="info">
|
||||
<Trans>
|
||||
@@ -171,30 +168,27 @@ function BirthdayInner({
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{isUnder13 && (
|
||||
<Admonition type="error">
|
||||
<Trans>
|
||||
You must be at least 13 years old to use Bluesky. Read our{' '}
|
||||
<SimpleInlineLinkText
|
||||
to="https://bsky.social/about/support/tos"
|
||||
label={_(msg`Terms of Service`)}>
|
||||
label={l`Terms of Service`}>
|
||||
Terms of Service
|
||||
</SimpleInlineLinkText>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<ErrorMessage message={errorMessage} style={[a.rounded_sm]} />
|
||||
) : undefined}
|
||||
|
||||
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)}
|
||||
label={hasChanged ? l`Save birthdate` : l`Done`}
|
||||
size="large"
|
||||
onPress={onSave}
|
||||
onPress={() => void onSave()}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={isUnder13}>
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
import {type TextInput, View} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
||||
useGifSearchQuery as useKlipyGifSearchQuery,
|
||||
} from '#/state/queries/klipy'
|
||||
import {
|
||||
type Gif,
|
||||
tenorUrlToBskyGifUrl,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
gifPreviewUrl,
|
||||
useTenorFeaturedGifsQuery,
|
||||
useTenorGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
@@ -85,7 +87,8 @@ function GifList({
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
@@ -93,11 +96,14 @@ function GifList({
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
const {height} = useWindowDimensions()
|
||||
const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
|
||||
const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
|
||||
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
|
||||
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -108,7 +114,13 @@ function GifList({
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
} = klipyEnabled
|
||||
? isSearching
|
||||
? klipySearch
|
||||
: klipyTrending
|
||||
: isSearching
|
||||
? tenorSearch
|
||||
: tenorTrending
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
@@ -158,7 +170,7 @@ function GifList({
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Close GIF dialog`)}>
|
||||
label={l`Close GIF dialog`}>
|
||||
<ButtonIcon icon={Arrow} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -166,8 +178,8 @@ function GifList({
|
||||
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
label={l`Search GIFs`}
|
||||
placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
@@ -185,7 +197,7 @@ function GifList({
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [gtMobile, t.atoms.bg, _, control])
|
||||
}, [gtMobile, t.atoms.bg, l, control, klipyEnabled])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -212,14 +224,18 @@ function GifList({
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
errorTitle={l`Failed to load GIFs`}
|
||||
errorMessage={
|
||||
klipyEnabled
|
||||
? l`There was an issue connecting to KLIPY.`
|
||||
: l`There was an issue connecting to Tenor.`
|
||||
}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
? l`No search results found for "${search}".`
|
||||
: klipyEnabled
|
||||
? l`No featured GIFs found. There may be an issue with KLIPY.`
|
||||
: l`No featured GIFs found. There may be an issue with Tenor.`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -246,23 +262,19 @@ function GifList({
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
style={a.gap_md}
|
||||
label={_(msg`An error has occurred`)}>
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
title={l`Oh no!`}
|
||||
message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
label={l`Close dialog`}
|
||||
onPress={() => control.close()}
|
||||
color="primary"
|
||||
size="large"
|
||||
@@ -284,7 +296,7 @@ export function GifPreview({
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
@@ -294,7 +306,7 @@ export function GifPreview({
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
label={l`Select GIF "${gif.title}"`}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
@@ -308,7 +320,7 @@ export function GifPreview({
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
|
||||
uri: gifPreviewUrl(gif.media_formats.tinygif.url),
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
@@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {
|
||||
canBeMessaged,
|
||||
type ConvoWithDetails,
|
||||
parseConvoView,
|
||||
} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
@@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {AvatarBubbles} from '../AvatarBubbles'
|
||||
import {Error} from '../Error'
|
||||
import {ProfileBadges} from '../ProfileBadges'
|
||||
|
||||
export type ProfileItem = {
|
||||
type: 'profile'
|
||||
@@ -38,6 +43,12 @@ export type ProfileItem = {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
type ExistingChatItem = {
|
||||
type: 'existingChat'
|
||||
key: string
|
||||
convo: ConvoWithDetails
|
||||
}
|
||||
|
||||
type EmptyItem = {
|
||||
type: 'empty'
|
||||
key: string
|
||||
@@ -54,7 +65,12 @@ type ErrorItem = {
|
||||
key: string
|
||||
}
|
||||
|
||||
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||
type Item =
|
||||
| ProfileItem
|
||||
| ExistingChatItem
|
||||
| EmptyItem
|
||||
| PlaceholderItem
|
||||
| ErrorItem
|
||||
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
@@ -72,12 +88,14 @@ export function SearchablePeopleList({
|
||||
onSelectChat?: undefined
|
||||
}
|
||||
| {
|
||||
onSelectChat: (did: string) => void
|
||||
onSelectChat: (
|
||||
chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string},
|
||||
) => void
|
||||
renderProfileCard?: undefined
|
||||
}
|
||||
)) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
@@ -105,7 +123,7 @@ export function SearchablePeopleList({
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
message: l`We're having network issues, try again`,
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
@@ -139,20 +157,27 @@ export function SearchablePeopleList({
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
for (const convoView of page.convos) {
|
||||
const convo = parseConvoView(convoView, currentAccount?.did)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
if (!convo) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
if (convo.kind === 'group') {
|
||||
_items.push({
|
||||
type: 'existingChat',
|
||||
key: convo.view.id,
|
||||
convo,
|
||||
})
|
||||
} else {
|
||||
if (convo.primaryMember.handle === 'missing.invalid') continue
|
||||
if (usedDids.has(convo.primaryMember.did)) continue
|
||||
|
||||
usedDids.add(convo.primaryMember.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
profile,
|
||||
type: 'existingChat',
|
||||
key: convo.view.id,
|
||||
convo: convo,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -209,7 +234,7 @@ export function SearchablePeopleList({
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
@@ -221,12 +246,27 @@ export function SearchablePeopleList({
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'existingChat': {
|
||||
if (renderProfileCard) {
|
||||
// should be unreachable
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<ExistingChatCard
|
||||
key={item.key}
|
||||
convo={item.convo}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={id => onSelectChat({kind: 'convo', id})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'profile': {
|
||||
if (renderProfileCard) {
|
||||
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
|
||||
@@ -236,7 +276,7 @@ export function SearchablePeopleList({
|
||||
key={item.key}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
onPress={did => onSelectChat({kind: 'user', did})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -247,11 +287,14 @@ export function SearchablePeopleList({
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
case 'error': {
|
||||
return <Error key={item.key} message={l`Failed to load profiles`} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onSelectChat, renderProfileCard],
|
||||
[moderationOpts, onSelectChat, renderProfileCard, l],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -293,7 +336,7 @@ export function SearchablePeopleList({
|
||||
</Text>
|
||||
{IS_WEB ? (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
label={l`Close`}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant={IS_WEB ? 'ghost' : 'solid'}
|
||||
@@ -328,7 +371,7 @@ export function SearchablePeopleList({
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
_,
|
||||
l,
|
||||
title,
|
||||
searchText,
|
||||
control,
|
||||
@@ -364,12 +407,13 @@ function DefaultProfileCard({
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const enabled = canBeMessaged(profile)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
const displayName = createSanitizedDisplayName(
|
||||
profile,
|
||||
true,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
@@ -380,7 +424,7 @@ function DefaultProfileCard({
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={_(msg`Start chat with ${displayName}`)}
|
||||
label={l`Start chat with ${displayName}`}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
@@ -422,6 +466,113 @@ function DefaultProfileCard({
|
||||
)
|
||||
}
|
||||
|
||||
function ExistingChatCard({
|
||||
convo,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (convoId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const enabled =
|
||||
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
|
||||
const moderation = moderateProfile(convo.primaryMember, moderationOpts)
|
||||
const name =
|
||||
convo.kind === 'group'
|
||||
? convo.details.name
|
||||
: createSanitizedDisplayName(
|
||||
convo.primaryMember,
|
||||
true,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
const handleOnPress = useCallback(() => {
|
||||
onPress(convo.view.id)
|
||||
}, [onPress, convo.view.id])
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={l`Select chat "${name}"`}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
a.px_lg,
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused || hovered
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size="small" />
|
||||
) : (
|
||||
<ProfileCard.Avatar
|
||||
profile={convo.primaryMember}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
)}
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
a.leading_snug,
|
||||
a.self_start,
|
||||
a.flex_shrink,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{convo.kind === 'direct' && (
|
||||
<ProfileBadges
|
||||
profile={convo.primaryMember}
|
||||
size="md"
|
||||
style={[a.pl_xs]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{convo.kind === 'direct' ? (
|
||||
<ProfileCard.Handle profile={convo.primaryMember} />
|
||||
) : (
|
||||
<>
|
||||
{enabled ? (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={2}>
|
||||
<Plural
|
||||
value={convo.members.length}
|
||||
one="# member"
|
||||
other="# members"
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Trans>Group is locked</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCardSkeleton() {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -488,7 +639,7 @@ function SearchInput({
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
@@ -512,7 +663,7 @@ function SearchInput({
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={_(msg`Search`)}
|
||||
placeholder={l`Search`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
@@ -532,8 +683,8 @@ function SearchInput({
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Searches for profiles`)}
|
||||
accessibilityLabel={l`Search profiles`}
|
||||
accessibilityHint={l`Searches for profiles`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -9,15 +9,18 @@ export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
onTap?: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<MessageContextMenu message={message}>
|
||||
<MessageContextMenu message={message} onTap={onTap}>
|
||||
{trigger =>
|
||||
// will always be true, since this file is platform split
|
||||
trigger.IS_NATIVE && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util'
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
hasReactions,
|
||||
isFromSelf,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
hasReactions?: boolean
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
onTap?: () => void
|
||||
}) {
|
||||
const viewRef = useRef(null)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -57,17 +60,17 @@ export function ActionsWrapper({
|
||||
) {
|
||||
convo
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} else {
|
||||
if (hasReachedReactionLimit(message, currentAccount?.did)) return
|
||||
convo.addReaction(message.id, emoji).catch(() =>
|
||||
Toast.show(_(msg`Failed to add emoji reaction`), {
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
[_, convo, message, currentAccount?.did],
|
||||
[l, convo, message, currentAccount?.did],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -87,6 +90,7 @@ export function ActionsWrapper({
|
||||
isFromSelf
|
||||
? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse]
|
||||
: [a.ml_xs, {marginRight: 'auto'}],
|
||||
hasReactions ? [a.mb_2xl] : undefined,
|
||||
]}>
|
||||
<EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
|
||||
{({props, state, IS_NATIVE, control}) => {
|
||||
@@ -133,10 +137,13 @@ export function ActionsWrapper({
|
||||
}}
|
||||
</MessageContextMenu>
|
||||
</View>
|
||||
<View
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={l`Click to view the date and time`}
|
||||
onPress={onTap}
|
||||
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
|
||||
{children}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {LayoutAnimation, type TextInput, View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||
import {EmptyMemberList} from './components/EmptyMemberList'
|
||||
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
|
||||
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
|
||||
import {UserLabel} from './components/UserLabel'
|
||||
import {UserSearchInput} from './components/UserSearchInput'
|
||||
|
||||
type LabelItem = {
|
||||
type: 'label'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type ProfileItem = {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
type EmptyItem = {
|
||||
type: 'empty'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type PlaceholderItem = {
|
||||
type: 'placeholder'
|
||||
key: string
|
||||
}
|
||||
|
||||
type ErrorItem = {
|
||||
type: 'error'
|
||||
key: string
|
||||
}
|
||||
|
||||
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||
|
||||
export type State = {
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'setDids'
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
}
|
||||
| {
|
||||
type: 'removeDids'
|
||||
groupChatDids: string[]
|
||||
groupChatProfiles: bsky.profile.AnyProfileView[]
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'setDids': {
|
||||
return {
|
||||
...state,
|
||||
groupChatDids: action.groupChatDids,
|
||||
groupChatProfiles: action.groupChatProfiles,
|
||||
}
|
||||
}
|
||||
case 'removeDids': {
|
||||
return {
|
||||
...state,
|
||||
groupChatDids: action.groupChatDids,
|
||||
groupChatProfiles: action.groupChatProfiles,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function AddMembersFlow({
|
||||
title,
|
||||
onAddMembers,
|
||||
}: {
|
||||
title: string
|
||||
onAddMembers: (dids: string[]) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const [footerHeight, setFooterHeight] = useState(0)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isError,
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
|
||||
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
||||
groupChatDids: [],
|
||||
groupChatProfiles: [],
|
||||
})
|
||||
|
||||
const onRemoveDid = useCallback(
|
||||
(did: string) => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
dispatch({
|
||||
type: 'removeDids',
|
||||
groupChatDids: groupChatDids.filter(d => d !== did),
|
||||
groupChatProfiles: groupChatProfiles.filter(
|
||||
profile => profile.did !== did,
|
||||
),
|
||||
})
|
||||
},
|
||||
[groupChatDids, groupChatProfiles],
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: l`We’re having network issues, try again`,
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((__, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
}
|
||||
|
||||
if (searchText === '') {
|
||||
_items.unshift({
|
||||
type: 'label',
|
||||
key: 'suggested',
|
||||
message: l`Suggested`,
|
||||
})
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [isError, searchText, l, results, currentAccount?.did, follows])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||
}
|
||||
|
||||
const handlePressBack = useCallback(() => {
|
||||
control.close()
|
||||
}, [control])
|
||||
|
||||
const handlePressAdd = useCallback(() => {
|
||||
onAddMembers(groupChatDids)
|
||||
}, [groupChatDids, onAddMembers])
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'label': {
|
||||
return <UserLabel key={item.key} message={item.message} />
|
||||
}
|
||||
case 'profile': {
|
||||
return (
|
||||
<GroupChatProfileCard
|
||||
key={item.key}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <EmptyMemberList key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (IS_WEB) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
let buttonLabel = l`Continue to group name`
|
||||
let buttonText = l`Next`
|
||||
let showButton = groupChatProfiles.length > 0
|
||||
let isButtonDisabled = !showButton
|
||||
|
||||
const showChatProfileTabs = groupChatProfiles.length > 0
|
||||
|
||||
const listHeader = useMemo(
|
||||
() => (
|
||||
<View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
web(a.pt_lg),
|
||||
native(a.pt_4xl),
|
||||
android({
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
}),
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.relative,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
web(a.pb_lg),
|
||||
]}>
|
||||
{IS_NATIVE ? (
|
||||
<Button
|
||||
label={l`Back`}
|
||||
size="large"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[native([a.absolute, a.z_20])]}
|
||||
onPress={handlePressBack}>
|
||||
<ButtonIcon icon={ArrowLeftIcon} size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
a.flex_grow,
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
a.text_center,
|
||||
a.px_5xl,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
{IS_WEB ? (
|
||||
<Button
|
||||
label={l`Close`}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[a.absolute, a.z_20, {right: -4}]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonIcon icon={XIcon} size="lg" />
|
||||
</Button>
|
||||
) : showButton ? (
|
||||
<Button
|
||||
label={buttonLabel}
|
||||
size="small"
|
||||
color="primary"
|
||||
style={[
|
||||
native([
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
{
|
||||
right: 8,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
disabled={isButtonDisabled}
|
||||
onPress={handlePressAdd}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={[web(a.pt_xs), native(a.pt_md)]}>
|
||||
<UserSearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{showChatProfileTabs ? (
|
||||
<View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
|
||||
<ChatProfileTabs
|
||||
testID="newGroupChatMembers"
|
||||
profiles={groupChatProfiles}
|
||||
onRemove={onRemoveDid}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[
|
||||
buttonLabel,
|
||||
control,
|
||||
groupChatProfiles,
|
||||
handlePressAdd,
|
||||
handlePressBack,
|
||||
isButtonDisabled,
|
||||
l,
|
||||
onRemoveDid,
|
||||
searchText,
|
||||
showButton,
|
||||
showChatProfileTabs,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.text_contrast_high,
|
||||
title,
|
||||
],
|
||||
)
|
||||
|
||||
const setGroupChatMembers = (dids: string[]) => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
|
||||
const added = dids.filter(d => !groupChatDids.includes(d))
|
||||
const removed = groupChatDids.filter(d => !dids.includes(d))
|
||||
const newDids = [
|
||||
...groupChatDids.filter(d => !removed.includes(d)),
|
||||
...added,
|
||||
]
|
||||
|
||||
const kept = groupChatProfiles.filter(p => dids.includes(p.did))
|
||||
const keptDids = new Set(kept.map(p => p.did))
|
||||
const addedProfiles = items
|
||||
.filter(
|
||||
(item): item is ProfileItem =>
|
||||
item.type === 'profile' &&
|
||||
dids.includes(item.profile.did) &&
|
||||
!keptDids.has(item.profile.did),
|
||||
)
|
||||
.map(item => item.profile)
|
||||
.sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
|
||||
|
||||
dispatch({
|
||||
type: 'setDids',
|
||||
groupChatDids: newDids,
|
||||
groupChatProfiles: [...kept, ...addedProfiles],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Toggle.Group
|
||||
values={groupChatDids}
|
||||
onChange={setGroupChatMembers}
|
||||
type="checkbox"
|
||||
label={l`Add group chat members`}
|
||||
style={web([a.contents])}>
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({height: '100%'}),
|
||||
]}
|
||||
webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
|
||||
keyboardDismissMode="on-drag"
|
||||
footer={
|
||||
IS_WEB ? (
|
||||
<Dialog.FlatListFooter
|
||||
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Button
|
||||
label={l`Back`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={handlePressBack}>
|
||||
<ButtonIcon icon={ArrowLeftIcon} size="md" />
|
||||
<ButtonText>
|
||||
{' '}
|
||||
<Trans>Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={buttonLabel}
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={isButtonDisabled}
|
||||
onPress={handlePressAdd}>
|
||||
<ButtonText>{buttonText} </ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.FlatListFooter>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Toggle.Group>
|
||||
)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ function MenuContent({
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
|
||||
const convoId = initialConvo.id
|
||||
const {data: convo} = useConvoQuery(initialConvo)
|
||||
const {data: convo} = useConvoQuery({convoId})
|
||||
|
||||
const onNavigateToProfile = useCallback(() => {
|
||||
navigation.navigate('Profile', {name: profile.did})
|
||||
|
||||
@@ -27,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
|
||||
})
|
||||
|
||||
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
let date: string
|
||||
const time = timeFormatter.format(new Date(dateStr))
|
||||
@@ -61,7 +61,6 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.text_center,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.px_md,
|
||||
]}>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {createContext, useCallback, useContext, useState} from 'react'
|
||||
|
||||
type DateDividerToggleContextType = {
|
||||
isDividerToggled: (id: string) => boolean
|
||||
toggleDivider: (id: string) => void
|
||||
}
|
||||
|
||||
const DateDividerToggleContext = createContext<DateDividerToggleContextType>({
|
||||
isDividerToggled: () => false,
|
||||
toggleDivider: () => {},
|
||||
})
|
||||
|
||||
export function DateDividerToggleProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [toggledIds, setToggledIds] = useState(new Set<string>())
|
||||
|
||||
const toggleDivider = useCallback((id: string) => {
|
||||
setToggledIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isDividerToggled = useCallback(
|
||||
(id: string) => toggledIds.has(id),
|
||||
[toggledIds],
|
||||
)
|
||||
|
||||
return (
|
||||
<DateDividerToggleContext.Provider
|
||||
value={{isDividerToggled, toggleDivider}}>
|
||||
{children}
|
||||
</DateDividerToggleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDateDividerToggle() {
|
||||
return useContext(DateDividerToggleContext)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function EmojiReactionPicker({
|
||||
const t = useTheme()
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
const {measurement, close} = useContextMenuContext()
|
||||
const {align} = useContextMenuMenuContext()
|
||||
const {align, xOffset} = useContextMenuMenuContext()
|
||||
const [layout, setLayout] = useState({width: 0, height: 0})
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
|
||||
@@ -44,12 +44,15 @@ export function EmojiReactionPicker({
|
||||
|
||||
const position = useMemo(() => {
|
||||
return {
|
||||
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
|
||||
x:
|
||||
align === 'left'
|
||||
? (measurement?.x ?? 0) + xOffset
|
||||
: (measurement?.x ?? 0) + (measurement?.width ?? 0) - layout.width,
|
||||
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
}
|
||||
}, [measurement, align, screenWidth, layout])
|
||||
}, [measurement, align, xOffset, screenWidth, layout])
|
||||
|
||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import {useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import EmojiPicker from '@emoji-mart/react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {DropdownMenu} from 'radix-ui'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||
import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type TriggerProps} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
|
||||
|
||||
@@ -22,19 +18,21 @@ export function EmojiReactionPicker({
|
||||
onEmojiSelect,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children?: TriggerProps['children']
|
||||
children?: EmojiPicker.TriggerProps['children']
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}) {
|
||||
if (!children)
|
||||
throw new Error('EmojiReactionPicker requires the children prop on web')
|
||||
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
|
||||
<EmojiPicker.Root onEmojiSelect={emoji => onEmojiSelect(emoji.native)}>
|
||||
<EmojiPicker.Trigger label={l`Add emoji reaction`}>
|
||||
{children}
|
||||
</EmojiPicker.Trigger>
|
||||
<MenuInner message={message} onEmojiSelect={onEmojiSelect} />
|
||||
</Menu.Root>
|
||||
</EmojiPicker.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,8 +47,6 @@ function MenuInner({
|
||||
const {control} = Menu.useMenuContext()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
useWebPreloadEmoji({immediate: true})
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const [prevOpen, setPrevOpen] = useState(control.isOpen)
|
||||
@@ -62,10 +58,6 @@ function MenuInner({
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmojiPickerResponse = (emoji: Emoji) => {
|
||||
handleEmojiSelect(emoji.native)
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
control.close()
|
||||
onEmojiSelect(emoji)
|
||||
@@ -74,18 +66,7 @@ function MenuInner({
|
||||
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
|
||||
|
||||
return expanded ? (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={5}
|
||||
collisionPadding={{left: 5, right: 5, bottom: 5}}>
|
||||
<div onWheel={evt => evt.stopPropagation()}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiPickerResponse}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
<EmojiPicker.Picker keepOpenWhenShiftHeld={false} />
|
||||
) : (
|
||||
<Menu.Outer style={[a.rounded_full]}>
|
||||
<View style={[a.flex_row, a.gap_xs]}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {LayoutAnimation, TextInput, View} from 'react-native'
|
||||
import {LayoutAnimation, type TextInput, View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon,
|
||||
ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon,
|
||||
} from '#/components/icons/Arrow'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -37,6 +35,11 @@ import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||
import {EmptyMemberList} from './components/EmptyMemberList'
|
||||
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
|
||||
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
|
||||
import {UserLabel} from './components/UserLabel'
|
||||
import {UserSearchInput} from './components/UserSearchInput'
|
||||
|
||||
type NewGroupChatItem = {
|
||||
type: 'newGroupChat'
|
||||
@@ -49,7 +52,7 @@ type LabelItem = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ProfileItem = {
|
||||
type ProfileItem = {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: bsky.profile.AnyProfileView
|
||||
@@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function InitiateChatFlow({
|
||||
title,
|
||||
onSelectChat,
|
||||
@@ -382,7 +386,7 @@ export function InitiateChatFlow({
|
||||
)
|
||||
}
|
||||
case 'label': {
|
||||
return <Label key={item.key} message={item.message} />
|
||||
return <UserLabel key={item.key} message={item.message} />
|
||||
}
|
||||
case 'profile': {
|
||||
switch (chatState) {
|
||||
@@ -417,7 +421,7 @@ export function InitiateChatFlow({
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
return <EmptyMemberList key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
@@ -560,7 +564,7 @@ export function InitiateChatFlow({
|
||||
</TextField.Root>
|
||||
</View>
|
||||
) : (
|
||||
<SearchInput
|
||||
<UserSearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
@@ -813,59 +817,6 @@ function DefaultProfileCard({
|
||||
)
|
||||
}
|
||||
|
||||
function GroupChatProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const enabled = canBeMessaged(profile)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
key={profile.did}
|
||||
disabled={!enabled}
|
||||
name={profile.did}
|
||||
label={displayName}
|
||||
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
size={44}
|
||||
disabledPreview
|
||||
/>
|
||||
<View>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
{enabled ? (
|
||||
<ProfileCard.Handle profile={profile} />
|
||||
) : (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Trans>{handle} can’t be messaged</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupChatMemberProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
@@ -902,106 +853,3 @@ function GroupChatMemberProfileCard({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCardSkeleton() {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
]}>
|
||||
<ProfileCard.AvatarPlaceholder size={42} />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Label({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.px_lg, a.py_sm]}>
|
||||
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onEscape,
|
||||
inputRef,
|
||||
}: {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const interacted = hovered || focused
|
||||
|
||||
return (
|
||||
<View
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<SearchIcon
|
||||
size="md"
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue - esb
|
||||
ref={inputRef}
|
||||
placeholder={l`Search for people`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
onEscape()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={l`Search profiles`}
|
||||
accessibilityHint={l`Searches for profiles`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@ import {hasReachedReactionLimit} from './util'
|
||||
export let MessageContextMenu = ({
|
||||
message,
|
||||
children,
|
||||
onTap,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
children: TriggerProps['children']
|
||||
onTap?: () => void
|
||||
}): React.ReactNode => {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
@@ -130,7 +132,8 @@ export let MessageContextMenu = ({
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
}: ${message.text}`}
|
||||
onTap={onTap}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
|
||||
+220
-352
@@ -1,17 +1,21 @@
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {memo, useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -22,28 +26,29 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {useDateDividerToggle} from './DateDividerToggle'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {ReactionsDialog} from './ReactionsDialog'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
@@ -51,19 +56,9 @@ const BORDER_RADIUS = 18
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
const DISPLAY_NAME_INSET = 22
|
||||
|
||||
// 42px avatar + 2 * 8px my_sm margins
|
||||
const ROW_HEIGHT = 58
|
||||
|
||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||
|
||||
type Reaction = {
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
|
||||
function isWithinCluster({
|
||||
isPending,
|
||||
adjacentMessage,
|
||||
@@ -108,8 +103,10 @@ let MessageItem = ({
|
||||
const {t: l} = useLingui()
|
||||
const {convo} = useConvoActive()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const reactionsControl = useDialogControl()
|
||||
const reactionTapRef = useRef(false)
|
||||
|
||||
const {message, nextMessage, prevMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
@@ -158,17 +155,31 @@ let MessageItem = ({
|
||||
new Date(prevMessage.sentAt).getTime() >
|
||||
MESSAGE_GAP_THRESHOLD_MS
|
||||
|
||||
const {isDividerToggled, toggleDivider} = useDateDividerToggle()
|
||||
const isDateDividerToggled = isDividerToggled(message.id)
|
||||
const isNextDateDividerToggled =
|
||||
nextMessage != null && isDividerToggled(nextMessage.id)
|
||||
const showDateDivider = hasLargeGapFromPrev
|
||||
|
||||
const isInCluster = !(isFirstInCluster && isLastInCluster)
|
||||
const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
|
||||
const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
|
||||
const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster)
|
||||
const isInMiddleOfCluster =
|
||||
isInCluster && !isFirstInCluster && !isLastInCluster
|
||||
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
|
||||
|
||||
const hasReactions = message.reactions && message.reactions.length > 0
|
||||
const prevHasReactions =
|
||||
prevIsMessage &&
|
||||
prevMessage.reactions != null &&
|
||||
prevMessage.reactions.length > 0
|
||||
const squaredBottomCorner =
|
||||
!hasReactions && isInCluster && (isInMiddleOfCluster || isFirstInCluster)
|
||||
!hasReactions &&
|
||||
isInCluster &&
|
||||
(isInMiddleOfCluster || effectiveFirstInCluster)
|
||||
const squaredTopCorner =
|
||||
isInCluster && (isInMiddleOfCluster || isLastInCluster)
|
||||
!prevHasReactions &&
|
||||
isInCluster &&
|
||||
(isInMiddleOfCluster || effectiveLastInCluster)
|
||||
|
||||
const pendingColor = t.palette.primary_300
|
||||
|
||||
@@ -179,13 +190,59 @@ let MessageItem = ({
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
|
||||
const targetBottomRadius =
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS
|
||||
const targetTopRadius = squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS
|
||||
|
||||
const bottomRadiusSV = useSharedValue(targetBottomRadius)
|
||||
const topRadiusSV = useSharedValue(targetTopRadius)
|
||||
|
||||
const showDisplayName =
|
||||
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
|
||||
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
|
||||
|
||||
useEffect(() => {
|
||||
bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300}))
|
||||
}, [targetBottomRadius, bottomRadiusSV])
|
||||
|
||||
useEffect(() => {
|
||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||
}, [targetTopRadius, topRadiusSV])
|
||||
|
||||
const borderRadiusStyle = useAnimatedStyle(() =>
|
||||
isFromSelf
|
||||
? {
|
||||
borderBottomRightRadius: bottomRadiusSV.get(),
|
||||
borderTopRightRadius: topRadiusSV.get(),
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius: bottomRadiusSV.get(),
|
||||
borderTopLeftRadius: topRadiusSV.get(),
|
||||
},
|
||||
)
|
||||
|
||||
const avatar = profile ? (
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
<Link
|
||||
label={l`${sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)}’s avatar`}
|
||||
accessibilityHint={l`Opens this profile`}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})}
|
||||
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
size={AVATAR_SIZE}
|
||||
moderationOpts={moderationOpts!}
|
||||
disabledPreview
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
@@ -248,108 +305,127 @@ let MessageItem = ({
|
||||
const appliedReactions = (
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{hasReactions ? (
|
||||
<>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.bottom_0,
|
||||
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
|
||||
a.px_sm,
|
||||
]}>
|
||||
<Pressable
|
||||
accessible={true}
|
||||
accessibilityLabel={reactionsLabel}
|
||||
accessibilityHint={
|
||||
isGroupChat ? l`Tap to view reactions` : undefined
|
||||
}
|
||||
style={[
|
||||
isFromSelf ? a.align_end : a.align_start,
|
||||
a.px_sm,
|
||||
a.pb_2xs,
|
||||
]}>
|
||||
<Pressable
|
||||
accessible={true}
|
||||
accessibilityLabel={reactionsLabel}
|
||||
accessibilityHint={
|
||||
isGroupChat ? l`Tap to view reactions` : undefined
|
||||
}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
isGroupChat ? reactionsControl.open() : undefined
|
||||
}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={
|
||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
||||
}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={group.value}
|
||||
style={[a.p_2xs]}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{group.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false},
|
||||
]}>
|
||||
{reactions.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</>
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.px_xs,
|
||||
isFromSelf ? a.justify_end : a.justify_start,
|
||||
a.flex_wrap,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_sm,
|
||||
{
|
||||
paddingTop: platform({android: 2, default: 3}),
|
||||
paddingBottom: platform({android: 2, default: 3}),
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPressIn={() => {
|
||||
// Don't toggle the date divider when tapping a reaction.
|
||||
reactionTapRef.current = true
|
||||
}}
|
||||
onPressOut={() => {
|
||||
// Include a delay here to account for tap-and-drag before release.
|
||||
setTimeout(() => {
|
||||
reactionTapRef.current = false
|
||||
}, 100)
|
||||
}}
|
||||
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
exiting={
|
||||
groupedReactions.length > 1 && native(ZoomOut.delay(200))
|
||||
}
|
||||
layout={native(LinearTransition.delay(300))}
|
||||
key={group.value}
|
||||
style={[a.py_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_xs,
|
||||
{textAlignVertical: 'center', includeFontPadding: false},
|
||||
]}>
|
||||
{group.value}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{groupedReactions.length !== reactions.length &&
|
||||
reactions.length > 1 ? (
|
||||
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
{textAlignVertical: 'center', includeFontPadding: false},
|
||||
]}>
|
||||
{reactions.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
members={convo.members}
|
||||
message={message}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
const messageInset = platform<ViewStyle | undefined>({
|
||||
ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm,
|
||||
android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
|
||||
web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDateDivider && (
|
||||
{(showDateDivider || isDateDividerToggled) && (
|
||||
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
|
||||
<DateDivider date={message.sentAt} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
isFirstInCluster && !showDateDivider && a.mt_sm,
|
||||
]}>
|
||||
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
|
||||
<View style={[a.relative]}>
|
||||
{isGroupChat && !isFromSelf && isLastInCluster ? (
|
||||
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
|
||||
{showAvatar ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
a.z_50,
|
||||
{
|
||||
transform: [{translateY: hasReactions ? -24 : 0}],
|
||||
},
|
||||
]}>
|
||||
{avatar}
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.flex_grow,
|
||||
!isFromSelf &&
|
||||
isGroupChat && {
|
||||
paddingLeft: AVATAR_SIZE,
|
||||
},
|
||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||
]}>
|
||||
{isGroupChat &&
|
||||
!isFromSelf &&
|
||||
isFirstInCluster &&
|
||||
!isOnlyEmoji(message.text) ? (
|
||||
{showDisplayName ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -363,9 +439,21 @@ let MessageItem = ({
|
||||
{displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
<ActionsWrapper
|
||||
hasReactions={hasReactions}
|
||||
isFromSelf={isFromSelf}
|
||||
message={message}
|
||||
onTap={() => {
|
||||
if (reactionTapRef.current) return
|
||||
if (!hasLargeGapFromPrev) {
|
||||
LayoutAnimation.configureNext(
|
||||
LayoutAnimation.Presets.easeInEaseOut,
|
||||
)
|
||||
toggleDivider(message.id)
|
||||
}
|
||||
}}>
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
<Animated.View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
style={[
|
||||
!isFromSelf && a.ml_sm,
|
||||
@@ -377,7 +465,7 @@ let MessageItem = ({
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
{
|
||||
marginTop: isFirstInCluster
|
||||
marginTop: effectiveFirstInCluster
|
||||
? 0
|
||||
: CLUSTERED_MESSAGE_GAP,
|
||||
backgroundColor: isFromSelf
|
||||
@@ -387,36 +475,34 @@ let MessageItem = ({
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {
|
||||
borderBottomRightRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
borderBottomLeftRadius:
|
||||
squaredBottomCorner || hasEmbedAndText
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
},
|
||||
borderRadiusStyle,
|
||||
]),
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
style={[
|
||||
a.text_md,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
// Emoji-only: add top leading to avoid clipping the
|
||||
// glyph, then pull the bottom up by the same amount so
|
||||
// the glyph bottom-aligns with the avatar instead of
|
||||
// sitting above its line-box baseline.
|
||||
isOnlyEmoji(message.text) && [
|
||||
a.leading_tight,
|
||||
// Visually align bottom of the emoji with the avatar
|
||||
!isFromSelf &&
|
||||
platform({
|
||||
android: {marginTop: a.mt_2xs.marginTop},
|
||||
default: {marginBottom: -a.mb_sm.marginBottom},
|
||||
}),
|
||||
],
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed
|
||||
@@ -430,7 +516,7 @@ let MessageItem = ({
|
||||
</ActionsWrapper>
|
||||
</View>
|
||||
</View>
|
||||
{isLastInCluster && (
|
||||
{effectiveLastInCluster && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={[isFromSelf ? a.text_right : a.text_left]}
|
||||
@@ -494,221 +580,3 @@ let MessageItemMetadata = ({
|
||||
}
|
||||
MessageItemMetadata = memo(MessageItemMetadata)
|
||||
export {MessageItemMetadata}
|
||||
|
||||
function ReactionsDialog({
|
||||
control,
|
||||
members,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
members: bsky.profile.AnyProfileView[]
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setSelected(value)
|
||||
}
|
||||
|
||||
const filteredMembers =
|
||||
selected === 'all'
|
||||
? members
|
||||
: members.filter(m =>
|
||||
reactions?.some(r => r.sender.did === m.did && r.value === selected),
|
||||
)
|
||||
|
||||
const minHeight = members.length * ROW_HEIGHT
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
nativeOptions={{preventExpansion: true, minHeight}}>
|
||||
<Dialog.Handle />
|
||||
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
|
||||
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
|
||||
<Trans>Reactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ReactionTabs
|
||||
groupedReactions={groupedReactions}
|
||||
selected={selected}
|
||||
totalReactions={reactions?.length ?? 0}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Reactions`}
|
||||
contentContainerStyle={[a.pt_0]}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{filteredMembers.map(profile => {
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
||||
)
|
||||
const handle = sanitizeHandle(profile?.handle ?? '', '@')
|
||||
const reaction = reactions?.find(
|
||||
({sender}) => sender.did === profile.did,
|
||||
)
|
||||
const rt = reaction
|
||||
? new RichTextAPI({text: reaction.value})
|
||||
: undefined
|
||||
|
||||
return rt ? (
|
||||
<View
|
||||
key={profile.did}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={2}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
})}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTabs({
|
||||
groupedReactions,
|
||||
selected,
|
||||
totalReactions,
|
||||
onFilter,
|
||||
}: {
|
||||
groupedReactions?: Reaction[]
|
||||
selected: string
|
||||
totalReactions: number
|
||||
onFilter: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const contentSize = useSharedValue(0)
|
||||
const scrollX = useSharedValue(0)
|
||||
|
||||
const handlePress = (value: string) => {
|
||||
onFilter(value)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
value: l`All`,
|
||||
senders: [],
|
||||
count: totalReactions,
|
||||
} as Reaction,
|
||||
...(groupedReactions ?? []),
|
||||
]
|
||||
|
||||
return (
|
||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
||||
<DraggableScrollView
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={e => {
|
||||
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_grow,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
]}
|
||||
onLayout={e => {
|
||||
contentSize.set(e.nativeEvent.layout.width)
|
||||
}}>
|
||||
{tabs?.map((reaction, index) => (
|
||||
<ReactionTab
|
||||
key={reaction.value}
|
||||
index={index}
|
||||
reaction={reaction}
|
||||
selected={selected}
|
||||
total={tabs.length}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</DraggableScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTab({
|
||||
index,
|
||||
reaction,
|
||||
selected,
|
||||
total,
|
||||
onPress,
|
||||
}: {
|
||||
index: number
|
||||
reaction: Reaction
|
||||
selected: string
|
||||
total: number
|
||||
onPress: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={
|
||||
reaction.key === 'all'
|
||||
? l`Tap to show all reactions `
|
||||
: l`Tap to show ${reaction.value} reactions`
|
||||
}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.mb_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||
]}
|
||||
onPress={() => onPress(reaction.key)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{l`${reaction.value} ${reaction.count}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ let MessageItemEmbed = ({
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
style={[
|
||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
||||
t.atoms.bg,
|
||||
a.rounded_md,
|
||||
!isFromSelf && a.ml_sm,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
width: Math.min(screen.width, 600) / 1.4,
|
||||
@@ -48,9 +46,10 @@ let MessageItemEmbed = ({
|
||||
<Embed
|
||||
embed={embed}
|
||||
allowNestedQuotes
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
viewContext={PostEmbedViewContext.ChatMessage}
|
||||
style={[
|
||||
a.rounded_xl,
|
||||
a.overflow_hidden,
|
||||
a.border_0,
|
||||
isFromSelf
|
||||
? {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type ModerationCause,
|
||||
type ModerationDecision,
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -11,10 +11,8 @@ import {useNavigation} from '@react-navigation/native'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -28,29 +26,13 @@ import {Link} from '#/components/Link'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
import {type ConvoWithDetails} from './util'
|
||||
|
||||
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
||||
|
||||
export function MessagesListHeader({
|
||||
profile,
|
||||
moderation,
|
||||
}: {
|
||||
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation?: ModerationDecision
|
||||
}) {
|
||||
export function MessagesListHeader({convo}: {convo?: ConvoWithDetails | null}) {
|
||||
const t = useTheme()
|
||||
|
||||
const blockInfo = useMemo(() => {
|
||||
if (!moderation) return
|
||||
const modui = moderation.ui('profileView')
|
||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
|
||||
const userBlock = blocks.find(alert => alert.source.type === 'user')
|
||||
return {
|
||||
listBlocks,
|
||||
userBlock,
|
||||
}
|
||||
}, [moderation])
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
return (
|
||||
<Layout.Header.Outer noBottomBorder={IS_LIQUID_GLASS}>
|
||||
@@ -58,12 +40,12 @@ export function MessagesListHeader({
|
||||
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||
<Layout.Header.BackButton />
|
||||
</View>
|
||||
{profile && moderation && blockInfo ? (
|
||||
<HeaderReady
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
{convo && moderationOpts ? (
|
||||
convo.kind === 'direct' ? (
|
||||
<ProfileHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
||||
) : (
|
||||
<GroupHeaderReady convo={convo} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
|
||||
@@ -94,135 +76,150 @@ export function MessagesListHeader({
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderReady({
|
||||
profile,
|
||||
moderation,
|
||||
blockInfo,
|
||||
function ProfileHeaderReady({
|
||||
convo,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ModerationDecision
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const {currentAccount} = useSession()
|
||||
const profile = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
const groupInfo = convoState.getGroupInfo?.()
|
||||
const isGroupChat = groupInfo != null
|
||||
const blockInfo = useMemo(() => {
|
||||
const modui = moderation.ui('profileView')
|
||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
|
||||
const userBlock = blocks.find(alert => alert.source.type === 'user')
|
||||
return {
|
||||
listBlocks,
|
||||
userBlock,
|
||||
}
|
||||
}, [moderation])
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isGroupChat
|
||||
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
|
||||
: isDeletedAccount
|
||||
? l`Deleted Account`
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
|
||||
const latestMessageFromOther = convoState.items.findLast(
|
||||
(item: ConvoItem) =>
|
||||
item.type === 'message' &&
|
||||
item.message.sender.did !== currentAccount?.did,
|
||||
)
|
||||
const displayName = isDeletedAccount
|
||||
? l`Deleted Account`
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
|
||||
const latestReportableMessage =
|
||||
latestMessageFromOther?.type === 'message'
|
||||
? latestMessageFromOther.message
|
||||
ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) &&
|
||||
convo.view.lastMessage.sender?.did !== currentAccount?.did
|
||||
? convo.view.lastMessage
|
||||
: undefined
|
||||
|
||||
const handleNavigateToSettings = () => {
|
||||
const convoId = convoState.convo?.id
|
||||
if (convoId) {
|
||||
navigation.navigate('MessagesConversationSettings', {
|
||||
conversation: convoId,
|
||||
})
|
||||
} else {
|
||||
logger.error(`handleNavigateToSettings: missing convo ID`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
{isGroupChat ? (
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
|
||||
<AvatarBubbles
|
||||
size="small"
|
||||
profiles={convoState.recipients ?? []}
|
||||
/>
|
||||
<Wrapper
|
||||
heading={
|
||||
<Link
|
||||
label={l`View ${displayName}’s profile`}
|
||||
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
label={l`View ${displayName}'s profile`}
|
||||
style={[a.flex_row, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_semi_bold, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
</Text>
|
||||
<BellOffIcon
|
||||
size="sm"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)}
|
||||
</Link>
|
||||
}
|
||||
muted={convo.view.muted}
|
||||
settings={
|
||||
<ConvoMenu
|
||||
convo={convo.view}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHeaderReady({
|
||||
convo,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const handleNavigateToSettings = () => {
|
||||
navigation.navigate('MessagesConversationSettings', {
|
||||
conversation: convo.view.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
heading={
|
||||
<>
|
||||
<AvatarBubbles size="small" profiles={convo.members} />
|
||||
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
|
||||
{convo.details.name}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
muted={convo.view.muted}
|
||||
settings={
|
||||
<Button
|
||||
label={l`Open group chat settings`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
style={[a.bg_transparent]}
|
||||
onPress={handleNavigateToSettings}>
|
||||
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
heading,
|
||||
muted,
|
||||
settings,
|
||||
}: {
|
||||
heading: React.ReactNode
|
||||
muted: boolean
|
||||
settings: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
|
||||
{heading}
|
||||
<MuteStatus muted={muted} />
|
||||
</View>
|
||||
|
||||
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||
<Layout.Header.Slot>
|
||||
{isConvoActive(convoState) ? (
|
||||
isGroupChat ? (
|
||||
<Button
|
||||
label={l`Open group chat settings`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
style={[a.bg_transparent]}
|
||||
onPress={handleNavigateToSettings}>
|
||||
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
|
||||
</Button>
|
||||
) : (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Layout.Header.Slot>
|
||||
<Layout.Header.Slot>{settings}</Layout.Header.Slot>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MuteStatus({muted}: {muted: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return muted ? (
|
||||
<>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}> · </Text>
|
||||
<BellOffIcon size="sm" style={t.atoms.text_contrast_medium} />
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
type ScrollView,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo'
|
||||
import {useSession} from '#/state/session'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type Reaction = {
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export function ReactionsDialog({
|
||||
control,
|
||||
members,
|
||||
message,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
members: bsky.profile.AnyProfileView[]
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setSelected(value)
|
||||
}
|
||||
|
||||
const filteredReactions = reactions?.filter(
|
||||
r => selected === 'all' || r.value === selected,
|
||||
)
|
||||
|
||||
const header = (
|
||||
<>
|
||||
<View style={[a.px_2xl, IS_WEB ? [a.pt_xl, a.pb_md] : a.pt_3xl]}>
|
||||
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
|
||||
<Trans>Reactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ReactionTabs
|
||||
groupedReactions={groupedReactions}
|
||||
selected={selected}
|
||||
totalReactions={reactions?.length ?? 0}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
<Dialog.Close />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
nativeOptions={{
|
||||
preventExpansion: true,
|
||||
minHeight: screenHeight / 2,
|
||||
maxHeight: screenHeight / 2,
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
{IS_NATIVE ? header : null}
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Reactions`}
|
||||
contentContainerStyle={[a.pt_0]}
|
||||
header={IS_WEB ? header : null}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
{filteredReactions
|
||||
?.sort((a, b) => {
|
||||
if (a.sender.did === currentAccount?.did) return -1
|
||||
if (b.sender.did === currentAccount?.did) return 1
|
||||
return 0
|
||||
})
|
||||
.map(reaction => {
|
||||
const sender = members.find(m => m.did === reaction.sender.did)
|
||||
if (!sender) return null
|
||||
return (
|
||||
<ReactionRow
|
||||
key={reaction.sender.did + '-' + reaction.value}
|
||||
control={control}
|
||||
convo={convo}
|
||||
currentAccount={currentAccount}
|
||||
message={message}
|
||||
profile={sender}
|
||||
reaction={reaction}
|
||||
allReactions={reactions ?? []}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionRow({
|
||||
control,
|
||||
convo,
|
||||
currentAccount,
|
||||
message,
|
||||
profile,
|
||||
reaction,
|
||||
allReactions,
|
||||
selected,
|
||||
setSelected,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
convo: ActiveConvoStates
|
||||
currentAccount?: bsky.profile.AnyProfileView
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
reaction: ChatBskyConvoDefs.ReactionView
|
||||
allReactions: ChatBskyConvoDefs.ReactionView[]
|
||||
selected: string
|
||||
setSelected: React.Dispatch<React.SetStateAction<string>>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const isFromSelf = currentAccount?.did === profile.did
|
||||
|
||||
const displayName = createSanitizedDisplayName(profile, true)
|
||||
const handle = sanitizeHandle(profile?.handle ?? '', '@')
|
||||
|
||||
const handleOnPress = () => {
|
||||
const remainingReactions =
|
||||
allReactions?.filter(
|
||||
r =>
|
||||
!(r.value === reaction.value && r.sender.did === currentAccount?.did),
|
||||
) ?? []
|
||||
|
||||
if (remainingReactions.length === 0) {
|
||||
control.close()
|
||||
} else if (
|
||||
selected !== 'all' &&
|
||||
!remainingReactions.some(r => r.value === reaction.value)
|
||||
) {
|
||||
// tab no longer exists
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setSelected('all')
|
||||
}
|
||||
|
||||
convo
|
||||
.removeReaction(message.id, reaction.value)
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type="user"
|
||||
hideLiveBadge
|
||||
/>
|
||||
<View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium, web([a.mt_xs])]}>
|
||||
{isFromSelf ? l`Tap to remove` : handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[a.text_5xl, {includeFontPadding: false}]} emoji>
|
||||
{reaction.value}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isFromSelf) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={l`Tap to remove your ${reaction.value} reaction`}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}
|
||||
onPress={handleOnPress}>
|
||||
{inner}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.justify_between,
|
||||
a.my_sm,
|
||||
]}>
|
||||
{inner}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTabs({
|
||||
groupedReactions,
|
||||
selected,
|
||||
totalReactions,
|
||||
onFilter,
|
||||
}: {
|
||||
groupedReactions?: Reaction[]
|
||||
selected: string
|
||||
totalReactions: number
|
||||
onFilter: (value: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
const scrollState = useRef({x: 0, width: 0})
|
||||
const tabLayouts = useRef<Map<string, {x: number; width: number}>>(new Map())
|
||||
|
||||
const handlePress = (value: string) => {
|
||||
onFilter(value)
|
||||
|
||||
// Scroll a partially-visible tab fully into view.
|
||||
const layout = tabLayouts.current.get(value)
|
||||
if (layout && scrollViewRef.current && scrollState.current.width > 0) {
|
||||
const tabLeft = layout.x
|
||||
const tabRight = layout.x + layout.width
|
||||
const viewLeft = scrollState.current.x
|
||||
const viewRight = viewLeft + scrollState.current.width
|
||||
|
||||
if (tabLeft < viewLeft) {
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: Math.max(0, tabLeft - 24),
|
||||
animated: true,
|
||||
})
|
||||
} else if (tabRight > viewRight) {
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: tabRight - scrollState.current.width + 24,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabLayout = (key: string, layout: {x: number; width: number}) => {
|
||||
tabLayouts.current.set(key, layout)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
value: l`All`,
|
||||
senders: [],
|
||||
count: totalReactions,
|
||||
} as Reaction,
|
||||
...(groupedReactions ?? []),
|
||||
]
|
||||
|
||||
return (
|
||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
||||
<DraggableScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal={true}
|
||||
scrollEventThrottle={16}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={e => {
|
||||
scrollState.current = {
|
||||
x: e.nativeEvent.contentOffset.x,
|
||||
width: e.nativeEvent.layoutMeasurement.width,
|
||||
}
|
||||
}}
|
||||
onLayout={e => {
|
||||
scrollState.current.width = e.nativeEvent.layout.width
|
||||
}}>
|
||||
<Animated.View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_grow,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
]}>
|
||||
{tabs?.map((reaction, index) => (
|
||||
<ReactionTab
|
||||
key={reaction.value}
|
||||
index={index}
|
||||
reaction={reaction}
|
||||
selected={selected}
|
||||
total={tabs.length}
|
||||
onPress={handlePress}
|
||||
onTabLayout={handleTabLayout}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</DraggableScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionTab({
|
||||
index,
|
||||
reaction,
|
||||
selected,
|
||||
total,
|
||||
onPress,
|
||||
onTabLayout,
|
||||
}: {
|
||||
index: number
|
||||
reaction: Reaction
|
||||
selected: string
|
||||
total: number
|
||||
onPress: (value: string) => void
|
||||
onTabLayout: (key: string, layout: {x: number; width: number}) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={
|
||||
reaction.key === 'all'
|
||||
? l`Tap to show all reactions`
|
||||
: l`Tap to show ${reaction.value} reactions`
|
||||
}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.mb_sm,
|
||||
selected === reaction.key
|
||||
? t.atoms.border_contrast_low
|
||||
: {borderColor: t.palette.contrast_50},
|
||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||
]}
|
||||
onLayout={e => {
|
||||
onTabLayout(reaction.key, {
|
||||
x: e.nativeEvent.layout.x,
|
||||
width: e.nativeEvent.layout.width,
|
||||
})
|
||||
}}
|
||||
onPress={() => onPress(reaction.key)}>
|
||||
<Text emoji style={[a.text_sm]}>
|
||||
{l`${reaction.value} ${reaction.count}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SystemMessageItem({
|
||||
item,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'system-message'}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {i18n} = useLingui()
|
||||
|
||||
const info = getSystemMessageInfo(item.message.data, item.relatedProfiles)
|
||||
if (!info) return null
|
||||
|
||||
const {Icon, message} = info
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.px_md,
|
||||
a.mt_md,
|
||||
a.mb_xs,
|
||||
]}>
|
||||
<Icon size="xs" style={[a.mr_2xs, t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false, textAlignVertical: 'center'},
|
||||
]}>
|
||||
{i18n._(message)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function EmptyMemberList({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function GroupChatProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const enabled = canBeMessaged(profile)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
key={profile.did}
|
||||
disabled={!enabled}
|
||||
name={profile.did}
|
||||
label={displayName}
|
||||
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
size={44}
|
||||
disabledPreview
|
||||
/>
|
||||
<View>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
{enabled ? (
|
||||
<ProfileCard.Handle profile={profile} />
|
||||
) : (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Trans>{handle} can’t be messaged</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
{enabled ? <Toggle.Checkbox /> : null}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
|
||||
export function ProfileCardSkeleton() {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
]}>
|
||||
<ProfileCard.AvatarPlaceholder size={42} />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function UserLabel({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.px_lg, a.py_sm]}>
|
||||
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
|
||||
export function UserSearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onEscape,
|
||||
inputRef,
|
||||
}: {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const interacted = hovered || focused
|
||||
|
||||
return (
|
||||
<View
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<SearchIcon
|
||||
size="md"
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue - esb
|
||||
ref={inputRef}
|
||||
placeholder={l`Search for people`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
onEscape()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={l`Search profiles`}
|
||||
accessibilityHint={l`Searches for profiles`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,15 @@ export function NewChat({
|
||||
[control, createGroupChat],
|
||||
)
|
||||
|
||||
const onSelectExistingChat = useCallback(
|
||||
(chatId: string) => {
|
||||
control.close(() => {
|
||||
onNewChat(chatId)
|
||||
})
|
||||
},
|
||||
[control, onNewChat],
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
@@ -112,7 +121,13 @@ export function NewChat({
|
||||
) : (
|
||||
<SearchablePeopleList
|
||||
title={l`Start a new chat`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectChat={chat => {
|
||||
if (chat.kind === 'user') {
|
||||
onCreateChat(chat.did)
|
||||
} else {
|
||||
onSelectExistingChat(chat.id)
|
||||
}
|
||||
}}
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,13 @@ function SendViaChatDialogInner({
|
||||
},
|
||||
})
|
||||
|
||||
const onSelectExistingChat = useCallback(
|
||||
(chatId: string) => {
|
||||
control.close(() => onSelectChat(chatId))
|
||||
},
|
||||
[control, onSelectChat],
|
||||
)
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
@@ -63,7 +70,13 @@ function SendViaChatDialogInner({
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectChat={chat => {
|
||||
if (chat.kind === 'user') {
|
||||
onCreateChat(chat.did)
|
||||
} else {
|
||||
onSelectExistingChat(chat.id)
|
||||
}
|
||||
}}
|
||||
showRecentConvos
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
|
||||
@@ -1 +0,0 @@
|
||||
export {TextInput} from 'react-native'
|
||||
@@ -0,0 +1,93 @@
|
||||
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
toShortUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
|
||||
export type UserMessageInfo = {
|
||||
message: string | null
|
||||
sentAt: string
|
||||
reportableMessage?: ChatBskyConvoDefs.MessageView
|
||||
}
|
||||
|
||||
export function getMessageInfo({
|
||||
convo,
|
||||
currentAccountDid,
|
||||
i18n,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
currentAccountDid: string | undefined
|
||||
i18n: I18n
|
||||
}): UserMessageInfo | null {
|
||||
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lastMessage = convo.lastMessage
|
||||
const isFromMe = lastMessage.sender?.did === currentAccountDid
|
||||
const senderDid = lastMessage.sender?.did
|
||||
const sender = convo.members.find(m => m.did === senderDid)
|
||||
const name = sender ? createSanitizedDisplayName(sender) : null
|
||||
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||
|
||||
const reportableMessage = isFromMe ? undefined : lastMessage
|
||||
|
||||
const prefix = (message: string) => {
|
||||
if (isFromMe) {
|
||||
return i18n._(
|
||||
msg({
|
||||
message: `You: ${message}`,
|
||||
comment: 'When the last message in a chat was made by you.',
|
||||
}),
|
||||
)
|
||||
} else if (isGroup && name) {
|
||||
return i18n._(
|
||||
msg({
|
||||
message: `${name}: ${message}`,
|
||||
comment:
|
||||
'When the last message in a group chat came from someone other than you.',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
let message: string | null = null
|
||||
|
||||
if (lastMessage.text) {
|
||||
message = prefix(lastMessage.text)
|
||||
} else if (lastMessage.embed) {
|
||||
const defaultEmbeddedContentMessage = i18n._(
|
||||
msg`(contains embedded content)`,
|
||||
)
|
||||
|
||||
if (AppBskyEmbedRecord.isView(lastMessage.embed)) {
|
||||
const embed = lastMessage.embed
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
|
||||
const record = embed.record
|
||||
const path = postUriToRelativePath(record.uri, {
|
||||
handle: record.author.handle,
|
||||
})
|
||||
const href = path ? toBskyAppUrl(path) : undefined
|
||||
const short = href ? toShortUrl(href) : defaultEmbeddedContentMessage
|
||||
message = prefix(short)
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
sentAt: lastMessage.sentAt,
|
||||
reportableMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
|
||||
export type UserReactionInfo = {
|
||||
message: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function getReactionInfo({
|
||||
convo,
|
||||
currentAccountDid,
|
||||
i18n,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
currentAccountDid: string | undefined
|
||||
i18n: I18n
|
||||
}): UserReactionInfo | null {
|
||||
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {reaction, message: reactedTo} = convo.lastReaction
|
||||
const isFromMe = reaction.sender.did === currentAccountDid
|
||||
const senderDid = reaction.sender.did
|
||||
const sender = convo.members.find(m => m.did === senderDid)
|
||||
const name = sender ? createSanitizedDisplayName(sender) : null
|
||||
|
||||
const lastMessageText = reactedTo.text
|
||||
const fallbackMessage = i18n._(
|
||||
msg({
|
||||
message: 'a message',
|
||||
comment:
|
||||
'If last message does not contain text, fall back to "{user} reacted to {a message}"',
|
||||
}),
|
||||
)
|
||||
const target = lastMessageText ? `"${lastMessageText}"` : fallbackMessage
|
||||
|
||||
let message: string
|
||||
if (isFromMe) {
|
||||
message = i18n._(msg`You reacted ${reaction.value} to ${target}`)
|
||||
} else if (name) {
|
||||
message = i18n._(msg`${name} reacted ${reaction.value} to ${target}`)
|
||||
} else {
|
||||
message = i18n._(msg`Someone reacted ${reaction.value} to ${target}`)
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
createdAt: reaction.createdAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
|
||||
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
|
||||
import {
|
||||
ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon,
|
||||
ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon,
|
||||
} from '#/components/icons/ChainLink'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {
|
||||
Lock_Stroke2_Corner0_Rounded as LockIcon,
|
||||
Unlock_Stroke2_Corner2_Rounded as UnlockIcon,
|
||||
} from '#/components/icons/Lock'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
||||
|
||||
export type SystemMessageInfo = {
|
||||
message: MessageDescriptor
|
||||
Icon: React.ComponentType<SVGIconProps>
|
||||
}
|
||||
|
||||
function getReferredDisplayName(
|
||||
user: ChatBskyConvoDefs.SystemMessageReferredUser,
|
||||
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
|
||||
): string | null {
|
||||
const profile = relatedProfiles.find(p => p.did === user.did)
|
||||
return profile ? createSanitizedDisplayName(profile) : null
|
||||
}
|
||||
|
||||
export function getSystemMessageInfo(
|
||||
data: ChatBskyConvoDefs.SystemMessageView['data'],
|
||||
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
|
||||
): SystemMessageInfo | null {
|
||||
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
|
||||
const name = getReferredDisplayName(data.member, relatedProfiles)
|
||||
return {
|
||||
Icon: JoinIcon,
|
||||
message: name
|
||||
? msg`${name} was added to the group`
|
||||
: msg`Someone was added to the group`,
|
||||
}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) {
|
||||
const name = getReferredDisplayName(data.member, relatedProfiles)
|
||||
return {
|
||||
Icon: LeaveIcon,
|
||||
message: name
|
||||
? msg`${name} was removed from the group`
|
||||
: msg`Someone was removed from the group`,
|
||||
}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) {
|
||||
const name = getReferredDisplayName(data.member, relatedProfiles)
|
||||
return {
|
||||
Icon: JoinIcon,
|
||||
message: name
|
||||
? msg`${name} joined the group`
|
||||
: msg`Someone joined the group`,
|
||||
}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) {
|
||||
const name = getReferredDisplayName(data.member, relatedProfiles)
|
||||
return {
|
||||
Icon: LeaveIcon,
|
||||
message: name ? msg`${name} left the group` : msg`Someone left the group`,
|
||||
}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) {
|
||||
return {Icon: LockIcon, message: msg`Chat locked`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataUnlockConvo(data)) {
|
||||
return {Icon: UnlockIcon, message: msg`Chat unlocked`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvoPermanently(data)) {
|
||||
return {Icon: LockIcon, message: msg`Chat locked permanently`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataEditGroup(data)) {
|
||||
return {
|
||||
Icon: PencilIcon,
|
||||
message: data.newName
|
||||
? msg`Chat title changed to ${data.newName}`
|
||||
: msg`Chat title changed`,
|
||||
}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) {
|
||||
return {Icon: ChainLinkIcon, message: msg`Invite link created`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataEditJoinLink(data)) {
|
||||
return {Icon: ChainLinkIcon, message: msg`Invite link edited`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataEnableJoinLink(data)) {
|
||||
return {Icon: ChainLinkIcon, message: msg`Invite link enabled`}
|
||||
} else if (ChatBskyConvoDefs.isSystemMessageDataDisableJoinLink(data)) {
|
||||
return {Icon: ChainLinkBrokenIcon, message: msg`Invite link disabled`}
|
||||
}
|
||||
return null
|
||||
}
|
||||
+102
-2
@@ -1,7 +1,8 @@
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {logger} from '#/logger'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
@@ -54,3 +55,102 @@ export function hasReachedReactionLimit(
|
||||
)
|
||||
return myReactions.length >= EMOJI_REACTION_LIMIT
|
||||
}
|
||||
|
||||
export type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
||||
// can be missing if account deleted
|
||||
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>
|
||||
}
|
||||
|
||||
export type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
||||
kind: $Typed<ChatBskyActorDefs.DirectConvoMember>
|
||||
}
|
||||
|
||||
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
||||
| {
|
||||
kind: 'group'
|
||||
details: ChatBskyConvoDefs.GroupConvo
|
||||
primaryMember: GroupConvoMember // the owner
|
||||
members: Array<GroupConvoMember>
|
||||
}
|
||||
| {
|
||||
kind: 'direct'
|
||||
details: ChatBskyConvoDefs.DirectConvo
|
||||
primaryMember: DirectConvoMember // the other user
|
||||
members: Array<DirectConvoMember>
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw convoView into something easier to use (i.e. extracts chat owner)
|
||||
* and enforces the correct type for convo members.
|
||||
*/
|
||||
export function parseConvoView(
|
||||
convoView: ChatBskyConvoDefs.ConvoView,
|
||||
ownDid: string | undefined,
|
||||
): ConvoWithDetails | null {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
convoView.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
let owner: GroupConvoMember | undefined = undefined
|
||||
|
||||
for (const member of convoView.members) {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
member.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
if (member.kind.role === 'owner') {
|
||||
// have to do a type assertion here
|
||||
// this works: {...member, kind: member.kind}
|
||||
// however that's creating a new object for no good reason
|
||||
owner = member as GroupConvoMember
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner) {
|
||||
logger.warn('No owner found in group convo')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
view: convoView,
|
||||
kind: 'group',
|
||||
details: convoView.kind,
|
||||
primaryMember: owner,
|
||||
members: convoView.members as Array<GroupConvoMember>,
|
||||
}
|
||||
} else if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||
convoView.kind,
|
||||
ChatBskyConvoDefs.isDirectConvo,
|
||||
)
|
||||
) {
|
||||
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
||||
|
||||
if (!otherUser) {
|
||||
logger.warn('No other user found in direct convo')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
view: convoView,
|
||||
kind: 'direct',
|
||||
details: convoView.kind,
|
||||
primaryMember: otherUser as DirectConvoMember,
|
||||
members: convoView.members as Array<DirectConvoMember>,
|
||||
}
|
||||
} else {
|
||||
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowBoxRight_Stroke2_Corner3_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
|
||||
})
|
||||
|
||||
export const ChainLinkBroken_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
|
||||
@@ -7,3 +7,7 @@ export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" 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-5Zm-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-1H7Z',
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -210,12 +210,17 @@ export function AutoSizedImage({
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={[
|
||||
style={({pressed}) => [
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{aspectRatio: max ?? 1},
|
||||
web([
|
||||
a.transition_transform,
|
||||
{transitionDuration: '200ms'},
|
||||
pressed && {transform: [{scale: 0.99}]},
|
||||
]),
|
||||
]}>
|
||||
{contents}
|
||||
</Pressable>
|
||||
@@ -237,7 +242,16 @@ export function AutoSizedImage({
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={[a.h_full]}>
|
||||
style={({pressed}) => [
|
||||
a.h_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
web([
|
||||
a.transition_transform,
|
||||
{transitionDuration: '200ms'},
|
||||
pressed && {transform: [{scale: 0.99}]},
|
||||
]),
|
||||
]}>
|
||||
{contents}
|
||||
</Pressable>
|
||||
</ConstrainedImage>
|
||||
|
||||
@@ -103,7 +103,14 @@ export function Gallery({
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
||||
const hideBadges = isWithinQuote
|
||||
const contentHeight = useMemo(() => {
|
||||
if (isWithinChat) {
|
||||
return 120
|
||||
}
|
||||
if (bps.gtMobile) {
|
||||
return 300
|
||||
} else if (bps.gtPhone) {
|
||||
@@ -111,10 +118,7 @@ export function Gallery({
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
}, [bps])
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const hideBadges = isWithinQuote
|
||||
}, [bps, isWithinChat])
|
||||
|
||||
/*
|
||||
* Container overflow styles
|
||||
@@ -253,7 +257,6 @@ export function Gallery({
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate={0.993}
|
||||
directionalLockEnabled
|
||||
nestedScrollEnabled
|
||||
alwaysBounceVertical={false}
|
||||
@@ -427,15 +430,20 @@ function GalleryImage({
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
style={[
|
||||
style={({pressed}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
web({
|
||||
cursor: 'inherit',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
}),
|
||||
web([
|
||||
{
|
||||
cursor: 'inherit',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
},
|
||||
a.transition_transform,
|
||||
{transitionDuration: '200ms'},
|
||||
pressed && {transform: [{scale: 0.99}]},
|
||||
]),
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
|
||||
@@ -21,8 +21,6 @@ export type PaletteColor = {
|
||||
textInverted: string
|
||||
link: string
|
||||
border: string
|
||||
borderDark: string
|
||||
icon: string
|
||||
[k: string]: string
|
||||
}
|
||||
export type Palette = Record<PaletteColorName, PaletteColor>
|
||||
|
||||
+31
-3
@@ -190,16 +190,44 @@ export async function resolveGif(
|
||||
agent: BskyAgent,
|
||||
gif: Gif,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
|
||||
const gifUrl = gif.media_formats.gif.url
|
||||
const params = new URLSearchParams()
|
||||
params.set('hh', String(gif.media_formats.gif.dims[1]))
|
||||
params.set('ww', String(gif.media_formats.gif.dims[0]))
|
||||
|
||||
// For Klipy GIFs, embed video format slugs so parseKlipyGif can
|
||||
// swap to the right format per platform at render time. Klipy uses
|
||||
// different filename slugs per format (unlike Tenor where format is
|
||||
// encoded in the URL ID), so this info must travel with the URL.
|
||||
try {
|
||||
const url = new URL(gifUrl)
|
||||
if (url.hostname === 'static.klipy.com') {
|
||||
const mp4Slug = getFileSlug(gif.media_formats.mp4?.url)
|
||||
const webmSlug = getFileSlug(gif.media_formats.webm?.url)
|
||||
if (mp4Slug) params.set('mp4', mp4Slug)
|
||||
if (webmSlug) params.set('webm', webmSlug)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const uri = `${gifUrl}?${params.toString()}`
|
||||
const altText = gif.content_description || gif.title
|
||||
return {
|
||||
type: 'external',
|
||||
uri,
|
||||
title: gif.content_description,
|
||||
description: createGIFDescription(gif.content_description),
|
||||
title: altText,
|
||||
description: createGIFDescription(altText),
|
||||
thumb: await imageToThumb(gif.media_formats.preview.url),
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSlug(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined
|
||||
const filename = url.split('/').pop()
|
||||
if (!filename) return undefined
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
|
||||
}
|
||||
|
||||
async function resolveExternal(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
|
||||
@@ -178,6 +178,11 @@ export const GIF_SEARCH = (params: string) =>
|
||||
export const GIF_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||
|
||||
export const GIF_KLIPY_SEARCH = (params: string) =>
|
||||
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
||||
export const GIF_KLIPY_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/klipy/v2/featured?${params}`
|
||||
|
||||
export const MAX_LABELERS = 20
|
||||
|
||||
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import {useCallback, useInsertionEffect, useRef} from 'react'
|
||||
|
||||
// This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
// change, the function itself will remain the same. This means that if you
|
||||
// use this at a higher level of your tree, and then some state you read in it
|
||||
// changes, there is no mechanism for anything below in the tree to "react"
|
||||
// to this change (e.g. by knowing to call your function again).
|
||||
//
|
||||
// Also, you should avoid calling the returned function during rendering
|
||||
// since the values captured by it are going to lag behind.
|
||||
export function useNonReactiveCallback<T extends Function>(fn: T): T {
|
||||
const ref = useRef(fn)
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
* change, the function itself will remain the same. This means that if you use
|
||||
* this at a higher level of your tree, and then some state you read in it
|
||||
* changes, there is no mechanism for anything below in the tree to "react" to
|
||||
* this change (e.g. by knowing to call your function again).
|
||||
*
|
||||
* Also, you should avoid calling the returned function during rendering since
|
||||
* the values captured by it are going to lag behind.
|
||||
*
|
||||
* For objects, see `useNonReactiveObject` instead.
|
||||
*/
|
||||
export function useNonReactiveCallback<T extends Function = () => void>(
|
||||
fn?: T,
|
||||
): T {
|
||||
const ref = useRef<T>((fn ?? noop) as T)
|
||||
useInsertionEffect(() => {
|
||||
ref.current = fn
|
||||
ref.current = (fn ?? noop) as T
|
||||
}, [fn])
|
||||
return useCallback(
|
||||
(...args: any) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useInsertionEffect, useRef} from 'react'
|
||||
|
||||
/**
|
||||
* This should be used sparingly. It erases reactivity, i.e. when the inputs
|
||||
* change, the returned object itself will remain the same. This means that if
|
||||
* you use this at a higher level of your tree, and then some state you read in
|
||||
* it changes, there is no mechanism for anything below in the tree to "react"
|
||||
* to this change (e.g. by knowing to call your function again).
|
||||
*
|
||||
* For callbacks, see `useNonReactiveCallback` instead.
|
||||
*/
|
||||
export function useNonReactiveObject<T extends Record<string, unknown>>(
|
||||
o: T,
|
||||
): React.RefObject<T> {
|
||||
const ref = useRef(o)
|
||||
useInsertionEffect(() => {
|
||||
ref.current = o
|
||||
}, [o])
|
||||
return ref
|
||||
}
|
||||
@@ -13,12 +13,10 @@ export interface UsePaletteValue {
|
||||
viewLight: ViewStyle
|
||||
btn: ViewStyle
|
||||
border: ViewStyle
|
||||
borderDark: ViewStyle
|
||||
text: TextStyle
|
||||
textLight: TextStyle
|
||||
textInverted: TextStyle
|
||||
link: TextStyle
|
||||
icon: TextStyle
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +40,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
||||
border: {
|
||||
borderColor: palette.border,
|
||||
},
|
||||
borderDark: {
|
||||
borderColor: palette.borderDark,
|
||||
},
|
||||
text: {
|
||||
color: palette.text,
|
||||
},
|
||||
@@ -57,9 +52,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
||||
link: {
|
||||
color: palette.link,
|
||||
},
|
||||
icon: {
|
||||
color: palette.icon,
|
||||
},
|
||||
}
|
||||
}, [theme, color])
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
export function useHotkeysContext() {
|
||||
return {
|
||||
enableScope: () => {},
|
||||
disableScope: () => {},
|
||||
}
|
||||
return useMemo(
|
||||
() => ({
|
||||
enableScope: noop,
|
||||
disableScope: noop,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
|
||||
'vimeo',
|
||||
'giphy',
|
||||
'tenor',
|
||||
'klipy',
|
||||
'flickr',
|
||||
'bandcamp',
|
||||
] as const
|
||||
@@ -44,6 +45,7 @@ export type EmbedPlayerType =
|
||||
| 'vimeo_video'
|
||||
| 'giphy_gif'
|
||||
| 'tenor_gif'
|
||||
| 'klipy_gif'
|
||||
| 'flickr_album'
|
||||
| 'bandcamp_album'
|
||||
| 'bandcamp_track'
|
||||
@@ -55,6 +57,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
twitch: 'Twitch',
|
||||
giphy: 'GIPHY',
|
||||
tenor: 'Tenor',
|
||||
klipy: 'KLIPY',
|
||||
spotify: 'Spotify',
|
||||
appleMusic: 'Apple Music',
|
||||
soundcloud: 'SoundCloud',
|
||||
@@ -391,6 +394,20 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
}
|
||||
|
||||
const klipyGif = parseKlipyGif(urlp)
|
||||
if (klipyGif.success) {
|
||||
const {playerUri, dimensions} = klipyGif
|
||||
|
||||
return {
|
||||
type: 'klipy_gif',
|
||||
source: 'klipy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
|
||||
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
|
||||
let i = urlp.pathname.length - 1
|
||||
@@ -628,3 +645,90 @@ export function isTenorGifUri(url: URL | string) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKlipyGif(urlp: URL):
|
||||
| {success: false}
|
||||
| {
|
||||
success: true
|
||||
playerUri: string
|
||||
dimensions: {height: number; width: number}
|
||||
} {
|
||||
if (urlp.hostname !== 'static.klipy.com') {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
if (!urlp.pathname.startsWith('/ii/')) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const h = urlp.searchParams.get('hh')
|
||||
const w = urlp.searchParams.get('ww')
|
||||
|
||||
if (!h || !w) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const dimensions = {
|
||||
height: Number(h),
|
||||
width: Number(w),
|
||||
}
|
||||
|
||||
// Validate dimensions are valid positive numbers
|
||||
if (
|
||||
isNaN(dimensions.height) ||
|
||||
isNaN(dimensions.width) ||
|
||||
dimensions.height <= 0 ||
|
||||
dimensions.width <= 0
|
||||
) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const playerUrl = new URL(urlp.href)
|
||||
playerUrl.hostname = 'k.gifs.bsky.app'
|
||||
|
||||
// On web, swap the gif filename for a video format so the <video>
|
||||
// element can play it. Klipy uses different filename slugs per
|
||||
// format (unlike Tenor's ID-based scheme), so the slugs are
|
||||
// embedded as query params at composition time by resolveGif().
|
||||
if (IS_WEB) {
|
||||
const webmSlug = playerUrl.searchParams.get('webm')
|
||||
const mp4Slug = playerUrl.searchParams.get('mp4')
|
||||
const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
|
||||
const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
|
||||
|
||||
// Without a slug we can't produce a playable video URL on web,
|
||||
// so fall back to the link card instead of returning a broken player.
|
||||
if (!slug) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const parts = playerUrl.pathname.split('/')
|
||||
parts[parts.length - 1] = `${slug}.${ext}`
|
||||
playerUrl.pathname = parts.join('/')
|
||||
}
|
||||
|
||||
// Strip all metadata params — only the path matters for the CDN
|
||||
playerUrl.searchParams.delete('hh')
|
||||
playerUrl.searchParams.delete('ww')
|
||||
playerUrl.searchParams.delete('mp4')
|
||||
playerUrl.searchParams.delete('webm')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
playerUri: playerUrl.href,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
export function isKlipyGifUri(url: URL | string) {
|
||||
try {
|
||||
return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isGifEmbed(url: URL | string) {
|
||||
return isTenorGifUri(url) || isKlipyGifUri(url)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ export const colors = {
|
||||
green3: '#20bc07',
|
||||
green4: '#148203',
|
||||
green5: '#082b03',
|
||||
|
||||
unreadNotifBg: '#ebf6ff',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,19 +17,6 @@ export const defaultTheme: Theme = {
|
||||
textInverted: lightPalette.white,
|
||||
link: lightPalette.primary_500,
|
||||
border: lightPalette.contrast_100,
|
||||
borderDark: lightPalette.contrast_200,
|
||||
icon: lightPalette.contrast_500,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: lightPalette.contrast_400,
|
||||
replyLine: lightPalette.contrast_100,
|
||||
replyLineDot: lightPalette.contrast_200,
|
||||
unreadNotifBg: lightPalette.primary_25,
|
||||
unreadNotifBorder: lightPalette.primary_100,
|
||||
postCtrl: lightPalette.contrast_500,
|
||||
brandText: lightPalette.primary_500,
|
||||
emptyStateIcon: lightPalette.contrast_300,
|
||||
borderLinkHover: lightPalette.contrast_300,
|
||||
},
|
||||
primary: {
|
||||
background: colors.blue3,
|
||||
@@ -39,8 +26,6 @@ export const defaultTheme: Theme = {
|
||||
textInverted: colors.blue3,
|
||||
link: colors.blue0,
|
||||
border: colors.blue4,
|
||||
borderDark: colors.blue5,
|
||||
icon: colors.blue4,
|
||||
},
|
||||
secondary: {
|
||||
background: colors.green3,
|
||||
@@ -50,8 +35,6 @@ export const defaultTheme: Theme = {
|
||||
textInverted: colors.green4,
|
||||
link: colors.green1,
|
||||
border: colors.green4,
|
||||
borderDark: colors.green5,
|
||||
icon: colors.green4,
|
||||
},
|
||||
inverted: {
|
||||
background: darkPalette.black,
|
||||
@@ -61,8 +44,6 @@ export const defaultTheme: Theme = {
|
||||
textInverted: darkPalette.black,
|
||||
link: darkPalette.primary_500,
|
||||
border: darkPalette.contrast_100,
|
||||
borderDark: darkPalette.contrast_200,
|
||||
icon: darkPalette.contrast_500,
|
||||
},
|
||||
error: {
|
||||
background: colors.red3,
|
||||
@@ -72,8 +53,6 @@ export const defaultTheme: Theme = {
|
||||
textInverted: colors.red3,
|
||||
link: colors.red1,
|
||||
border: colors.red4,
|
||||
borderDark: colors.red5,
|
||||
icon: colors.red4,
|
||||
},
|
||||
},
|
||||
shapes: {
|
||||
@@ -303,19 +282,6 @@ export const darkTheme: Theme = {
|
||||
textInverted: darkPalette.black,
|
||||
link: darkPalette.primary_500,
|
||||
border: darkPalette.contrast_100,
|
||||
borderDark: darkPalette.contrast_200,
|
||||
icon: darkPalette.contrast_500,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: darkPalette.contrast_400,
|
||||
replyLine: darkPalette.contrast_200,
|
||||
replyLineDot: darkPalette.contrast_200,
|
||||
unreadNotifBg: darkPalette.primary_25,
|
||||
unreadNotifBorder: darkPalette.primary_100,
|
||||
postCtrl: darkPalette.contrast_500,
|
||||
brandText: darkPalette.primary_500,
|
||||
emptyStateIcon: darkPalette.contrast_300,
|
||||
borderLinkHover: darkPalette.contrast_300,
|
||||
},
|
||||
primary: {
|
||||
...defaultTheme.palette.primary,
|
||||
@@ -333,8 +299,6 @@ export const darkTheme: Theme = {
|
||||
textInverted: darkPalette.white,
|
||||
link: lightPalette.primary_500,
|
||||
border: lightPalette.contrast_100,
|
||||
borderDark: lightPalette.contrast_200,
|
||||
icon: lightPalette.contrast_500,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -352,19 +316,6 @@ export const dimTheme: Theme = {
|
||||
textInverted: dimPalette.black,
|
||||
link: dimPalette.primary_500,
|
||||
border: dimPalette.contrast_100,
|
||||
borderDark: dimPalette.contrast_200,
|
||||
icon: dimPalette.contrast_500,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: dimPalette.contrast_400,
|
||||
replyLine: dimPalette.contrast_200,
|
||||
replyLineDot: dimPalette.contrast_200,
|
||||
unreadNotifBg: dimPalette.primary_25,
|
||||
unreadNotifBorder: dimPalette.primary_100,
|
||||
postCtrl: dimPalette.contrast_500,
|
||||
brandText: dimPalette.primary_500,
|
||||
emptyStateIcon: dimPalette.contrast_300,
|
||||
borderLinkHover: dimPalette.contrast_300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user