Compare commits

..

83 Commits

Author SHA1 Message Date
Eric Bailey b410ba867f Run react imports codemod 2026-03-11 11:32:43 -05:00
pfrazee f5994af620 Nightly source-language update 2026-03-11 03:04:18 +00:00
Samuel Newman 8b67a3ec2e First pass at fixing large type by adding maxFontSizeMultiplier (#10037) 2026-03-10 23:39:02 +02:00
Samuel Newman 6187e79b0e Update React Native Screens and React Navigation to latest (#9993)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-10 15:32:41 -05:00
Spence Pope 2bd9811652 [APP-1928] add bot/automated account badge and self-labeling settings (#10008)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-10 13:28:49 -05:00
pfrazee 8bdb1c30c8 Nightly source-language update 2026-03-10 03:05:11 +00:00
Samuel Newman aa897f55a0 The Great Unjanking of the Sheets (#9973) 2026-03-09 22:53:32 +02:00
devin ivy 18d7e775f6 ogcard: ensure we use jpeg images for satori compat (#10023) 2026-03-07 09:11:57 +00:00
pfrazee b8aae166d9 Nightly source-language update 2026-03-07 02:59:25 +00:00
DS Boyce f8886fbfe6 Refine presentation of translation results (#10015)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-06 13:36:58 -08:00
Samuel Newman 041e348581 Bump version to 1.119.0 (#10020) 2026-03-06 13:34:03 -08:00
Samuel Newman e2c54a858c Move dev-env to subdirectory (#10009) 2026-03-06 11:45:49 -06:00
Eric Bailey 60a0edbbe2 [APP-1960] Fix current session refresh bug (#10019) 2026-03-06 11:43:14 -06:00
pfrazee 562bf3be22 Nightly source-language update 2026-03-06 03:06:49 +00:00
DS Boyce 290e0f2b54 Disable animations for translation events on Android (#10013) 2026-03-05 15:01:29 -08:00
DS Boyce ece6dc251c Link to profile via handle vs did (#10012) 2026-03-05 12:52:39 -08:00
Eric Bailey 8c705864a2 Prep 1.118.1 patch relase (#10011) 2026-03-05 14:43:18 -06:00
Eric Bailey 7a08b82810 Fix deps (#10010) 2026-03-05 14:11:57 -06:00
Samuel Newman 20bf2cd117 Prevent over-eager translate button (#10007) 2026-03-05 12:07:34 -08:00
DS Boyce fe8e8ce7de Add README.md for lib/translation (#10005) 2026-03-05 10:53:25 -08:00
Eric Bailey 8b8acb7bd1 Add docs on directory structures and naming conventions (#10002) 2026-03-05 12:31:55 -06:00
DS Boyce 1a487d0943 Remove duplicate punctuation (#10004) 2026-03-05 10:16:20 -08:00
Samuel Newman 1ae77aa997 Fix findNodeHandle usage on web (#10001) 2026-03-05 07:22:13 -08:00
Samuel Newman 7cec490cef Use a different icon for testflight builds (#10000) 2026-03-05 07:04:59 -08:00
Eric Bailey 1281eb01e6 Prep 1.118.0 (#9999) 2026-03-04 21:08:55 -06:00
pfrazee 4339585b27 Nightly source-language update 2026-03-05 03:06:58 +00:00
Samuel Newman 30c9ea5789 v1.118.0 E2E tests (#9994) 2026-03-04 20:45:08 -06:00
DS Boyce afbade6f6f Refine UX for on-device translation for posts (#9987)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-04 20:37:44 -06:00
Samuel Newman 1782a65174 Add iOS 26 fluid zoom transition to alt text dialog (#9970)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 20:32:08 -06:00
Eric Bailey 7fd218c97d Update API package to fix stale session issues (#9998) 2026-03-04 18:27:12 -06:00
DS Boyce 8446efb739 Pass feed URI to sendInteractions endpoint (#9989) 2026-03-04 12:38:24 -08:00
pfrazee b84f28f884 Nightly source-language update 2026-03-04 03:05:02 +00:00
DS Boyce 7dc3b4fa8e Add search event analytics (#9949)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-03 14:49:38 -06:00
Samuel Newman 37a82761f5 Cache profile view before opening AfterReportDialog (#9962) 2026-03-03 13:50:22 -06:00
Samuel Newman 612a778361 Set mute word dialog text input return key type to "done" (#9967)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-03 13:35:09 -06:00
Samuel Newman 162a78e1ae Add 🪿 (#9983) 2026-03-03 06:06:13 -08:00
pfrazee c42502fdcb Nightly source-language update 2026-03-03 03:09:48 +00:00
Samuel Newman 8b1c47a499 bskyweb: use avatar thumbnail for post og:image fallback (#9923) 2026-03-02 22:28:16 +00:00
Samuel Newman c2fd87bd8b Remove JPG hardcoding from image processing systems (#9955) 2026-03-02 20:23:07 +00:00
Samuel Newman d247a65683 Add Trans to string (#9981) 2026-03-02 11:39:50 -08:00
Spence Pope a95b877ea3 [APP-1917] fix header measurement for android content disappearing on switch (#9964)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-03-02 14:20:16 -05:00
Samuel Newman f614bf650d Translation followups (#9980) 2026-03-02 11:01:09 -08:00
Samuel Newman 5ee667f307 Force relayout to fix stuck header blur (#9979) 2026-03-02 10:50:00 -08:00
Spence Pope 9c29b2867a [APP-1882] fix email not updating in session state after email change (#9953) 2026-03-02 06:09:21 -08:00
smileyhead 9746dd8e4c Make ALT labels translatable (#9976) 2026-03-02 13:14:07 +00:00
DS Boyce 8e2a5abfff Fix crash with translate link on web (#9972) 2026-02-28 23:53:42 +00:00
pfrazee 6cdc1fe2b5 Nightly source-language update 2026-02-28 02:56:49 +00:00
DS Boyce 9bbcb472ef Use on-device translation on mobile when available (#9930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 16:59:19 -08:00
Samuel Newman 7c9f05a2af ContextMenu - return item to the right location if keyboard hides (#9963) 2026-02-27 22:19:04 +00:00
Samuel Newman d4357b2cb8 Fix iOS sheet keyboard handling using native scrollview features (#9959) 2026-02-27 13:59:24 -08:00
Spence Pope 576761a645 [APP-1819] fix progress guide flash and extra gap (#9960) 2026-02-27 08:31:35 -08:00
DS Boyce 8a1f8997fe Fix infinite loading spinner when changing search terms (#9950)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 03:42:31 -08:00
pfrazee 6cb88ce1f3 Nightly source-language update 2026-02-27 03:06:31 +00:00
Samuel Newman 65c4b7833d Blur effect on home screen (iOS 26) (#9935) 2026-02-26 07:00:12 -08:00
Spence Pope 9b24d75c9c [APP-1876] Fix pull-to-refresh getting stuck on tab switch (#9951) 2026-02-26 02:06:30 -08:00
Spence Pope 398df1a4b5 ensure app session state refreshes on reactivation (#9954)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:06:16 -08:00
pfrazee db9c2a1596 Nightly source-language update 2026-02-26 03:07:29 +00:00
Alex Benzer 00816b70dc [APP-1859] pinned feed drag n drop (#9893)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: vineyardbovines <spencerfpope@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:52:10 -08:00
Samuel Newman 49272be36b Fix up polyfills, remove unused deps (#9946) 2026-02-25 11:43:46 -08:00
surfdude29 85ffc77983 Show hashtag symbol for Mute option in menu (#9942) 2026-02-25 01:18:20 -08:00
pfrazee 95bede2dcb Nightly source-language update 2026-02-25 03:09:49 +00:00
surfdude29 e656fe688d Align strings from 'subtitles' to 'captions' (#9941) 2026-02-24 21:56:12 +00:00
Samuel Newman db1f45de92 iOS 26 tweaks #2 (#9938) 2026-02-24 12:14:19 -08:00
Samuel Newman bc61314c41 iOS 26 followups (#9936) 2026-02-24 08:12:57 -08:00
mozzius 1850042c37 Nightly source-language update 2026-02-24 07:17:35 +00:00
Samuel Newman 782edbf281 Restore type assert that got accidentally removed (#9934) 2026-02-23 23:11:39 -08:00
Samuel Newman 73b096e443 iOS 26 (#9047)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:34:52 -08:00
pfrazee 9b0f0a8d24 Nightly source-language update 2026-02-24 03:09:35 +00:00
Samuel Newman 9ec06971af Upgrade Lingui to v5 (#9905) 2026-02-23 20:28:03 +00:00
Samuel Newman 75242b9b1f Bump version to v1.118.0 (#9931) 2026-02-23 12:04:58 -08:00
pfrazee 21e53b226d Nightly source-language update 2026-02-22 03:10:59 +00:00
surfdude29 02c2a9ea7c Mark admonition text for translation in CreateListFromStarterPackDialog (#9922) 2026-02-21 16:53:44 +00:00
Eric Bailey cb7e2ab976 Release prep 1.117.0 (#9920) 2026-02-20 14:30:14 -06:00
Eric Bailey 067c6dacc9 Update location copy (#9918) 2026-02-20 13:20:04 -06:00
Eric Bailey 9b8c46cdb7 Add A/A test (#9917) 2026-02-20 13:19:56 -06:00
Samuel Newman f3cc850b62 Fix up typescript config (#9910) 2026-02-20 18:07:17 +00:00
Samuel Newman 35f9ed82b9 Fix string concat bug with trending topics muteword moderation (#9914) 2026-02-20 11:29:08 -06:00
Samuel Newman 8c4fc087f8 Add ability to convert starter pack to list (#9675)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@users.noreply.github.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-20 11:27:55 -06:00
Samuel Newman f3ddc074a6 Fork MCEmojiPicker (#9763) 2026-02-20 10:39:48 -06:00
Spence Pope 5e0ffc0917 [APP-1809] add cleanup button for unavailable moderation services (#9901)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-20 10:23:01 -06:00
Samuel Newman 3fae42c077 Remove hardcoded warning color, add yellow and pink to ALF palette (#9911) 2026-02-20 10:17:11 -06:00
Sylwia 80c0aa35e2 Add CORS headers to /oembed API (#9845) 2026-02-19 22:07:49 -08:00
Eric Bailey 4e3c3e9905 [APP-1805] Analytics tweaks and tracking callbacks (#9861) 2026-02-19 15:31:35 -06:00
728 changed files with 192029 additions and 162442 deletions
+3 -1
View File
@@ -110,7 +110,9 @@ google-services.json
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
src/locale/locales/**/messages.js
src/locale/locales/**/messages.mjs
src/locale/locales/**/messages.ts
# local builds
*.apk
+118 -1
View File
@@ -46,6 +46,7 @@ src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── features/ # Macro-features that bridge components/screens
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
@@ -60,6 +61,121 @@ src/
└── Navigation.tsx # Main navigation configuration
```
### Project Structure in Depth
When building new things, follow these guidelines for where to put code.
#### Components vs Screens vs Features
**Components** are reusable UI elements that are not full screens. Should be
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
these in `/components` if they are shared across screens.
**Screens** are full-page components that represent a route in the app. They
often contain multiple components and handle layout for a page. New screens
should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code.
For complex screens that have specific components or data needs that _are not
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`.
**Features** are higher-level modules that may include context, data fetching,
components, and utilities related to a specific feature e.g.
`/features/liveNow`. They don't neatly fit into components or screens and often
span multiple screens. This is an optional pattern for organizing complex
features.
#### Legacy Directories
For the most part, avoid writing new files into the `/view` directory and
subdirectories. This is the older pattern for organizing screens and components,
and it has become a bit disorganized over time. New development should go into
`/screens`, `/components`, and `/features`.
#### State
The `/state` directory is where we've historically put all our data fetching and
state management logic. This is perfectly fine, but for new features, consider
organizing state logic closer to the components that use it, either within a
feature directory or co-located with a screen. The key is to keep related code
together and avoid having "god files" with too much unrelated logic.
#### Lib
The `/lib` directory is for utilities and helpers that don't fit into other
categories. This can include things like API clients, formatting functions,
constants, and other shared logic.
#### Top Level Directories
Avoid writing new top-level subdirectories within `/src`. We've done this for a
few things in the past that, but we have stronger patterns now. Examples:
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
better classified within `/features`. We will probably migrate these things
eventually.
### File and Directory Naming Conventions
Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it
should be broken down into multiple files. For "macro" component cases, or
things that live in `/features` or `/screens`, we often follow a pattern of
having an `index.tsx` for the main component, and then co-locating related
components, hooks, and utilities in the same directory. For example:
```
src
├── screens/
│ ├── ProfileScreen/
│ │ ├── index.tsx # Main screen component
│ │ ├── components/ # Sub-components used only by this screen
```
Similar patterns can be found in `/features` and `/components`. The idea here is
to keep related code together and make it easier to navigate.
You should ask yourself: if someone new was looking for the code related to this
feature or screen, where would they expect to find it? Organizing code in a way
that matches developer expectations can make the codebase much more
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
easier to understand than having it scattered across multiple directories.
No need to go overboard with this. If a component or feature fits into a single
file, there's no reason to have a `/Component/index.tsx` file when it could just
be `/Component.tsx`. Use your judgment based on the complexity and amount of
related code.
#### Platform Specific Files
We have conflicting patterns in the app for this. The preferred approach is to
group platform-specific files into a directory as much as possible. For example,
rather than having `Component.tsx`, `Component.web.tsx`, and
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
itself well to this, since the `index.tsx` can be the main component file, and
the `README.md` can provide documentation for the whole feature. This is
optional, but can be a nice way to keep documentation close to the code it
describes.
Similarly, if there are tests that are specific to a component or feature, it
can be helpful to include them in the same directory, either as
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
related to the component or feature in one place and makes it easier to find and
maintain tests.
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
@@ -271,7 +387,8 @@ import * as TextField from '#/components/forms/TextField'
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, Trans, plural} from '@lingui/macro'
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
-1
View File
@@ -39,7 +39,6 @@ appId: xyz.blueskyweb.app
id: "editListNameInput"
- eraseText
- inputText: "Bad Ppl"
- hideKeyboard
- tapOn:
id: "editListDescriptionInput"
- eraseText
+12 -6
View File
@@ -35,9 +35,12 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
id: "feed-timeline-moveDown"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
@@ -55,9 +58,12 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
id: "feed-feed-moveDown"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
+5
View File
@@ -15,6 +15,11 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "customServerTextInput"
- inputText: "http://localhost:3000"
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
- tapOn: "Done"
- tapOn:
id: "loginUsernameInput"
@@ -26,6 +26,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "report:details"
- inputText: "This is a test report"
- hideKeyboard
- tapOn:
id: "report:submit"
- assertNotVisible:
-529
View File
@@ -1,529 +0,0 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from '../jest/test-pds'
async function main() {
let server: TestPDS
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(
await server.mocker.createPost(user, `Post ${i}`),
)
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account',
'muted-by-list-acc',
'blocking-account',
'blockedby-account',
'mutual-block-acc',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount(
'not-a-real-label',
'unknown-account',
)
await server.mocker.labelProfile(
'not-a-real-label',
'unknown-profile',
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createPost('unknown-posts', 'unknown post'),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createQuotePost(
'unknown-posts',
'unknown quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createReply(
'unknown-posts',
'unknown reply',
anchorPost,
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createPost(
'no-promote-posts',
'no-promote post',
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'muted quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'muted reply',
anchorPost,
)
const list = await server.mocker.createMuteList(
'alice',
'Muted Users',
)
await server.mocker.addToMuteList(
'alice',
list,
server.mocker.users['muted-by-list-acc'].did,
)
await server.mocker.createPost('muted-by-list-acc', 'muted post')
await server.mocker.createQuotePost(
'muted-by-list-acc',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-by-list-acc',
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost(
'mutual-block-acc',
'mutual-block post',
)
await server.mocker.createQuotePost(
'mutual-block-acc',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-acc',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-acc'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-acc'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-acc'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
return res
.writeHead(200, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
pdsUrl: server.pdsUrl,
appviewDid: server.appviewDid,
}),
)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
}
main()
+23 -9
View File
@@ -3,15 +3,29 @@ appId: xyz.blueskyweb.app
- launchApp:
appId: "xyz.blueskyweb.app"
clearState: true
- waitForAnimationToEnd
- tapOn: "http://localhost:8081"
- waitForAnimationToEnd
- extendedWaitUntil:
visible: "Continue"
- swipe:
from: "Bluesky"
direction: DOWN
duration: 100
arguments:
"-EXDevMenuIsOnboardingFinished": true
- runFlow:
when:
platform: iOS
commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- runFlow:
when:
visible: 'Open in "Bluesky"'
commands:
- tapOn: Open
- runFlow:
when:
platform: Android
commands:
- tapOn: 'http://localhost:8081'
- runFlow:
label: "Dismiss Expo dev menu"
when:
visible: "Continue"
commands:
- back
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
-6
View File
@@ -1,6 +0,0 @@
export default {
requestPermission: jest.fn(),
onForegroundEvent: jest.fn(),
setBadgeCount: jest.fn(),
displayNotification: jest.fn(),
}
@@ -1,9 +0,0 @@
export const CameraRoll = {
getPhotos: jest.fn().mockResolvedValue({
edges: [
{node: {image: {uri: 'path/to/image1.jpg'}}},
{node: {image: {uri: 'path/to/image2.jpg'}}},
{node: {image: {uri: 'path/to/image3.jpg'}}},
],
}),
}
@@ -1,4 +0,0 @@
export default {
configure: jest.fn().mockResolvedValue(0),
finish: jest.fn(),
}
-1
View File
@@ -1 +0,0 @@
export default {}
-10
View File
@@ -1,10 +0,0 @@
jest.mock('rn-fetch-blob', () => {
return {
__esModule: true,
default: {
fs: {
unlink: jest.fn(),
},
},
}
})
-2
View File
@@ -1,2 +0,0 @@
export const DropdownMenu = jest.fn().mockImplementation(() => {})
export const create = jest.fn().mockImplementation(() => {})
+6
View File
@@ -1,4 +1,5 @@
import {RichText} from '@atproto/api'
import {i18n} from '@lingui/core'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {
@@ -6,6 +7,7 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
@@ -202,6 +204,9 @@ describe('enforceLen', () => {
})
describe('cleanError', () => {
// cleanError uses lingui
i18n.loadAndActivate({locale: 'en', messages})
const inputs = [
'TypeError: Network request failed',
'Error: Aborted',
@@ -327,6 +332,7 @@ describe('shortenLinks', () => {
expect(outputRT.text).toEqual(outputs[i][0])
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
for (let j = 0; j < outputs[i][1].length; j++) {
// @ts-expect-error whatever
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
}
}
+16 -6
View File
@@ -35,6 +35,13 @@ module.exports = function (_config) {
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const IOS_ICON_FILE =
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: IS_TESTFLIGHT
? './assets/app-icons/ios_icon_testflight.icon'
: './assets/app-icons/ios_icon_default.icon'
return {
expo: {
version: VERSION,
@@ -55,10 +62,7 @@ module.exports = function (_config) {
config: {
usesNonExemptEncryption: false,
},
icon:
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: './assets/app-icons/ios_icon_default.icon',
icon: IOS_ICON_FILE,
infoPlist: {
UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription:
@@ -112,7 +116,6 @@ module.exports = function (_config) {
'zh-Hans',
'zh-Hant',
],
UIDesignRequiresCompatibility: true,
},
associatedDomains: ASSOCIATED_DOMAINS,
entitlements: {
@@ -256,9 +259,16 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 35,
compileSdkVersion: 36,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
buildReactNativeFromSource: IS_PRODUCTION,
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

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

After

Width:  |  Height:  |  Size: 621 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 303 B

+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = function (api) {
],
],
plugins: [
'macros',
'@lingui/babel-plugin-lingui-macro',
['babel-plugin-react-compiler', {target: '19'}],
[
'module:react-native-dotenv',
+10
View File
@@ -10,6 +10,7 @@ import logo from '../../assets/logo_full_name.svg'
import {Like as LikeIcon} from '../icons/Like'
import {Reply as ReplyIcon} from '../icons/Reply'
import {Repost as RepostIcon} from '../icons/Repost'
import {Robot as RobotIcon} from '../icons/Robot'
import {CONTENT_LABELS} from '../labels'
import * as bsky from '../types/bsky'
import {niceDate} from '../util/nice-date'
@@ -43,6 +44,9 @@ export function Post({thread}: Props) {
}
const verification = getVerificationState({profile: post.author})
const isBot = post.author.labels?.some(
l => l.val === 'bot' && l.src === post.author.did,
)
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
@@ -76,6 +80,12 @@ export function Post({thread}: Props) {
size={15}
/>
)}
{isBot && (
<RobotIcon
className="pl-[3px] mt-px shrink-0 text-slate-500 dark:text-slate-400"
size={15}
/>
)}
</div>
<Link
href={`/profile/${post.author.did}`}
+22
View File
@@ -0,0 +1,22 @@
import {h} from 'preact'
export const Robot = ({
size = 14,
className,
}: {
size?: number
className?: string
}) => (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 0C13.1046 0 14 0.89543 14 2C14 2.73976 13.5971 3.3835 13 3.72949V5H17.2002C18.8802 5 19.7206 5.00018 20.3623 5.32715C20.9265 5.61472 21.3853 6.07347 21.6729 6.6377C21.9998 7.27941 22 8.11978 22 9.7998V10.0498C23.1411 10.2814 24 11.2905 24 12.5C24 13.7094 23.141 14.7175 22 14.9492V15C22 17.8 21.9999 19.2 21.4551 20.2695C20.9757 21.2103 20.2103 21.9757 19.2695 22.4551C18.2 22.9999 16.8 23 14 23H10C7.20005 23 5.79998 22.9999 4.73047 22.4551C3.78966 21.9757 3.02429 21.2103 2.54492 20.2695C2.00013 19.2 2 17.8 2 15V14.9492C0.858955 14.7175 0 13.7094 0 12.5C0 11.2905 0.85886 10.2814 2 10.0498V9.7998C2 8.11978 2.00018 7.27941 2.32715 6.6377C2.61472 6.07347 3.07347 5.61472 3.6377 5.32715C4.27941 5.00018 5.11978 5 6.7998 5H11V3.72949C10.4029 3.3835 10 2.73976 10 2C10 0.89543 10.8954 0 12 0ZM8 10C6.89543 10 6 10.8954 6 12V14C6 15.1046 6.89543 16 8 16C9.10457 16 10 15.1046 10 14V12C10 10.8954 9.10457 10 8 10ZM16 10C14.8954 10 14 10.8954 14 12V14C14 15.1046 14.8954 16 16 16C17.1046 16 18 15.1046 18 14V12C18 10.8954 17.1046 10 16 10Z"
fill="currentColor"
/>
</svg>
)
+2 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {type SVGAttributes} from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
export function Butterfly(props: SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+15 -3
View File
@@ -1,10 +1,22 @@
import React from 'react'
import {type ImgHTMLAttributes} from 'react'
// @NOTE satori does not currently support webp, see vercel/satori#273
function detectMime(buf: Buffer): string {
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
return 'image/jpeg'
}
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
props: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
<img
{...others}
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
/>
)
}
+1 -2
View File
@@ -1,6 +1,5 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
+10 -5
View File
@@ -1,9 +1,8 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import {type Express} from 'express'
import satori from 'satori'
import {
@@ -11,7 +10,7 @@ import {
STARTERPACK_HEIGHT,
STARTERPACK_WIDTH,
} from '../components/StarterPack.js'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js'
@@ -83,12 +82,18 @@ export default function (ctx: AppContext, app: Express) {
}
async function getImage(url: string) {
const response = await fetch(url)
const response = await fetch(ensureJpeg(url))
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
function ensureJpeg(url: string) {
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
+7
View File
@@ -2,12 +2,14 @@ package main
import (
"net/url"
"strings"
"github.com/flosch/pongo2/v6"
)
func init() {
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
}
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
@@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
// Return the cleaned URL
return pongo2.AsValue(parsedURL.String()), nil
}
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
urlStr := in.String()
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
}
+1
View File
@@ -292,6 +292,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
+8 -1
View File
@@ -178,6 +178,13 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// Create CORS middleware for oembed
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
})
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
@@ -205,7 +212,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed)
e.GET("/oembed", server.WebOEmbed, oembedCORS)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
+2 -2
View File
@@ -35,8 +35,8 @@
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
+510
View File
@@ -0,0 +1,510 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from './test-pds'
let server: TestPDS
// eslint-disable-next-line @typescript-eslint/no-misused-promises
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(await server.mocker.createPost(user, `Post ${i}`))
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account',
'muted-by-list-acc',
'blocking-account',
'blockedby-account',
'mutual-block-acc',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount('not-a-real-label', 'unknown-account')
await server.mocker.labelProfile('not-a-real-label', 'unknown-profile')
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createPost('unknown-posts', 'unknown post'),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createQuotePost(
'unknown-posts',
'unknown quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createReply(
'unknown-posts',
'unknown reply',
anchorPost,
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createPost('no-promote-posts', 'no-promote post'),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'muted quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'muted reply',
anchorPost,
)
const list = await server.mocker.createMuteList('alice', 'Muted Users')
await server.mocker.addToMuteList(
'alice',
list,
server.mocker.users['muted-by-list-acc'].did,
)
await server.mocker.createPost('muted-by-list-acc', 'muted post')
await server.mocker.createQuotePost(
'muted-by-list-acc',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-by-list-acc',
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('mutual-block-acc', 'mutual-block post')
await server.mocker.createQuotePost(
'mutual-block-acc',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-acc',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-acc'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-acc'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-acc'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
return res
.writeHead(200, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
pdsUrl: server.pdsUrl,
appviewDid: server.appviewDid,
}),
)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
+12
View File
@@ -0,0 +1,12 @@
{
"name": "dev-env",
"version": "0.0.0",
"scripts": {
"e2e:mock-server": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
},
"dependencies": {
"@atproto/dev-env": "^0.3.213",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+4308
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
- TextEncoder / TextDecoder
- react-native-url-polyfill
- Array#findLast (on web)
- setImmediate (on web)
### Sentry sourcemaps
+21 -6
View File
@@ -73,7 +73,7 @@ import { Text } from "react-native";
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/macro";
import { Trans } from "@lingui/react/macro";
<Text><Trans>Hello World</Trans></Text>
```
@@ -90,18 +90,33 @@ const text = "Hello World";
```
In this case, you can use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/macro";
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
```jsx
import { useLingui } from "@lingui/react/macro";
const { t } = useLingui();
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
```
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/core/macro";
// not ideal - t only gets called once at module evaluation time
const text = t`Hello World`;
// however, this is suitable for strings that are ephemeral:
function sayHello() {
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
}
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
@@ -121,7 +136,7 @@ So the workflow is as follows:
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
+14
View File
@@ -3,12 +3,26 @@
Make sure you've copied `.env.example` to `.env.test` and provided any required
values.
Install dependencies in `/dev-env`
```
cd dev-env && yarn
```
## Using Maestro
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
### Running on Android
You will need to allow your device access to the port that the mock server is running on.
```
adb reverse tcp:3000 tcp:3000
```
### Running Maestro tests
- In one tab, run `yarn e2e:mock-server`
-2
View File
@@ -23,8 +23,6 @@ export default defineConfig(
{
ignores: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party/**',
'ios/**',
'android/**',
'coverage/**',
+2 -2
View File
@@ -1,7 +1,5 @@
/* global jest */
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
import {configure} from '@testing-library/react-native'
@@ -36,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => {
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
moveAsync: jest.fn().mockResolvedValue(undefined),
createDownloadResumable: jest.fn(),
}))
@@ -45,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
+6 -4
View File
@@ -1,5 +1,7 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
import {defineConfig} from '@lingui/cli'
export default defineConfig({
sourceLocale: 'en',
locales: [
'en',
'an',
@@ -49,5 +51,5 @@ module.exports = {
include: ['src'],
},
],
format: 'po',
}
compileNamespace: 'ts',
})
@@ -25,8 +25,8 @@ class BottomSheetModule : Module() {
view.dismiss()
}
AsyncFunction("updateLayout") { view: BottomSheetView ->
view.updateLayout()
Prop("fullHeight") { view: BottomSheetView, prop: Boolean ->
view.fullHeight = prop
}
Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
@@ -48,6 +48,8 @@ class BottomSheetModule : Module() {
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.preventExpansion = prop
}
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
}
}
}
@@ -8,10 +8,7 @@ import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
@@ -34,11 +31,20 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val screenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
// Native content height observation (eliminates JS bridge round-trip)
private var contentLayoutListener: View.OnLayoutChangeListener? = null
private var observedChildren: List<View> = emptyList()
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
context.resources.displayMetrics.heightPixels.toFloat()
} else {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat()
}
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
@@ -64,8 +70,15 @@ class BottomSheetView(
set(value) {
field = value
this.dialog?.setCancelable(!value)
// Full-height sheets have no half-expanded snap point, so any drag
// would dismiss. Disable dragging when dismiss is prevented.
if (fullHeight) {
this.setDraggable(!value && !disableDrag)
}
}
var fullHeight = false
var preventExpansion = false
var minHeight = 0f
@@ -129,6 +142,7 @@ class BottomSheetView(
}
private fun destroy() {
this.stopObservingContentHeight()
this.isClosing = false
this.isOpen = false
this.dialog = null
@@ -193,31 +207,40 @@ class BottomSheetView(
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
it.elevation = 0f
val behavior = BottomSheetBehavior.from(it)
behavior.state = BottomSheetBehavior.STATE_HIDDEN
behavior.isFitToContents = true
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.skipCollapsed = true
behavior.isDraggable = true
behavior.isHideable = true
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
if (fullHeight) {
behavior.isFitToContents = false
behavior.expandedOffset = getStatusBarHeight()
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
} else if (preventExpansion) {
behavior.isFitToContents = true
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
this.selectedSnapPoint = 1
} else {
behavior.isFitToContents = false
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
behavior.expandedOffset = getStatusBarHeight()
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
this.selectedSnapPoint = 1
}
}
behavior.addBottomSheetCallback(
@@ -226,12 +249,23 @@ class BottomSheetView(
bottomSheet: View,
newState: Int,
) {
if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
return
}
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
}
// Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) {
pendingLayoutUpdate = false
updateLayout()
}
}
override fun onSlide(
@@ -245,25 +279,14 @@ class BottomSheetView(
this.isOpening = true
dialog.show()
this.dialog = dialog
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = imeVisible
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!imeVisible && wasKeyboardVisible) {
updateLayout()
}
insets
if (!fullHeight) {
this.startObservingContentHeight()
}
}
fun updateLayout() {
if (fullHeight) return
val dialog = this.dialog ?: return
val contentHeight = this.getContentHeight()
@@ -274,21 +297,34 @@ class BottomSheetView(
val oldRatio = behavior.halfExpandedRatio
val newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (isKeyboardVisible) {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
// Don't update during user gestures — defer until the gesture completes.
if (currentState == BottomSheetBehavior.STATE_DRAGGING) {
pendingLayoutUpdate = true
return
}
behavior.halfExpandedRatio = newRatio
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
it.requestLayout()
}
// During settling (programmatic animation from our own state change),
// redirect the animation to the new position if the ratio changed.
if (currentState == BottomSheetBehavior.STATE_SETTLING) {
if (oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
return
}
if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -299,21 +335,77 @@ class BottomSheetView(
}
fun dismiss() {
this.dialog?.dismiss()
val dialog = this.dialog ?: return
// Mark as closing so the content observer doesn't fight the dismiss
// animation by calling updateLayout() mid-hide.
this.isClosing = true
// Temporarily make cancelable so cancel() works — cancel() gives the
// slide-out animation, while dismiss() does a plain fade.
dialog.setCancelable(true)
dialog.cancel()
}
// Observe each direct child of innerView via OnLayoutChangeListener so that
// height updates are detected purely on the native side. We use OnLayoutChangeListener
// (not OnGlobalLayoutListener) because React Native calls view.layout() directly
// via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener
// fires from setFrame() which IS called by layout(), so it catches RN updates.
private fun startObservingContentHeight() {
stopObservingContentHeight()
val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
}
val children = mutableListOf<View>()
for (i in 0 until innerViewGroup.childCount) {
val child = innerViewGroup.getChildAt(i)
child.addOnLayoutChangeListener(listener)
children.add(child)
}
this.contentLayoutListener = listener
this.observedChildren = children
// Pick up current height if content is already laid out
val contentHeight = getContentHeight()
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
private fun stopObservingContentHeight() {
contentLayoutListener?.let { listener ->
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
}
contentLayoutListener = null
observedChildren = emptyList()
lastObservedContentHeight = 0f
}
// Util
private fun getContentHeight(): Float {
val innerView = this.innerView ?: return 0f
var index = 0
innerView.allViews.forEach {
if (index == 1) {
return it.height.toFloat()
}
index++
val innerView = this.innerView as? ViewGroup ?: return 0f
// Use the tallest direct child's height. The handle is absolutely positioned
// (overlaps the content), so summing would double-count its height as padding.
var maxChildHeight = 0f
for (i in 0 until innerView.childCount) {
val h = innerView.getChildAt(i).height.toFloat()
if (h > maxChildHeight) maxChildHeight = h
}
return 0f
return maxChildHeight
}
private fun getTargetHeight(): Float {
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="ThemeOverlay.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge, matching react-native-edge-to-edge's setup -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:fitsSystemWindows">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
@@ -16,5 +18,6 @@
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
<item name="backgroundTint">@android:color/transparent</item>
</style>
</resources>
@@ -19,8 +19,8 @@ public class BottomSheetModule: Module {
view.dismiss()
}
AsyncFunction("updateLayout") { (view: SheetView) in
view.updateLayout()
Prop("fullHeight") { (view: SheetView, prop: Bool) in
view.fullHeight = prop
}
Prop("cornerRadius") { (view: SheetView, prop: Float) in
@@ -42,6 +42,10 @@ public class BottomSheetModule: Module {
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
view.sourceViewTag = prop
}
}
}
}
+42 -9
View File
@@ -8,6 +8,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
private var innerView: UIView?
private var touchHandler: RCTTouchHandler?
// Native content height observation (eliminates JS bridge round-trip)
private var contentHeightObservation: NSKeyValueObservation?
// Events
private let onAttemptDismiss = EventDispatcher()
private let onSnapPointChange = EventDispatcher()
@@ -23,9 +26,11 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
// React view props
var fullHeight = false
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var sourceViewTag: Int?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
@@ -67,7 +72,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
}
}
private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier?
// MARK: - Lifecycle
@@ -105,6 +109,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
private func destroy() {
self.contentHeightObservation?.invalidate()
self.contentHeightObservation = nil
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
@@ -127,7 +133,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
let sheetVc = SheetViewController()
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight)
if let sheet = sheetVc.sheetPresentationController {
sheet.delegate = self
sheet.preferredCornerRadius = self.cornerRadius
@@ -135,8 +141,20 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
sheetVc.view.addSubview(innerView)
if #available(iOS 26.0, *),
let tag = self.sourceViewTag,
let bridge = self.appContext?.reactBridge,
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
sheetVc.preferredTransition = .zoom { _ in
return sourceView
}
}
self.sheetVc = sheetVc
self.isOpening = true
if !self.fullHeight {
self.startObservingContentHeight()
}
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
@@ -144,15 +162,30 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
}
func updateLayout() {
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil,
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
// Observe the content view's bounds via KVO so that height changes are detected
// purely on the native side, without a JS bridge round-trip through onLayout.
// Calls updateDetents directly with the observed height rather than going through
// updateLayout(), which has a prevLayoutDetentIdentifier guard that can block
// legitimate content-driven updates when detent identifiers drift during animations.
private func startObservingContentHeight() {
self.contentHeightObservation?.invalidate()
guard let contentView = self.innerView?.subviews.first else { return }
self.contentHeightObservation = contentView.observe(
\.bounds,
options: [.old, .new]
) { [weak self] _, change in
guard let self = self,
(self.isOpen || self.isOpening) && !self.isClosing,
let oldBounds = change.oldValue,
let newBounds = change.newValue,
oldBounds.height != newBounds.height,
newBounds.height > 0 else { return }
let clampedHeight = self.clampHeight(newBounds.height)
self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion)
self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
}
self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier
}
func dismiss() {
@@ -20,13 +20,32 @@ class SheetViewController: UIViewController {
}
}
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
func setDetents(contentHeight: CGFloat, preventExpansion: Bool, fullHeight: Bool = false) {
guard let sheet = self.sheetPresentationController,
let screenHeight = Util.getScreenHeight()
else {
return
}
if fullHeight {
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
return
}
// On iOS 26, the floaty sheet presentation adds the device bottom safe area
// on top of the custom detent value, creating visible padding inside the pill.
// Subtract it so the pill height matches our actual content.
var bottomSafeAreaAdjustment: CGFloat = 0
if #available(iOS 26.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
bottomSafeAreaAdjustment = window.safeAreaInsets.bottom
}
}
let adjustedHeight = contentHeight - bottomSafeAreaAdjustment
if #available(iOS 16.0, *) {
if contentHeight > screenHeight - 100 {
sheet.detents = [
@@ -36,7 +55,7 @@ class SheetViewController: UIViewController {
} else {
sheet.detents = [
.custom { _ in
return contentHeight
return adjustedHeight
}
]
if !preventExpansion {
@@ -1,5 +1,4 @@
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
@@ -25,7 +24,9 @@ export interface BottomSheetViewProps {
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
disableDrag?: boolean
sourceViewTag?: number
fullHeight?: boolean
minHeight?: number
maxHeight?: number
@@ -1,17 +1,18 @@
import * as React from 'react'
import {Component, createRef} from 'react'
import {type ComponentType, type ContextType, type RefObject} from 'react'
import {
Dimensions,
type LayoutChangeEvent,
type NativeSyntheticEvent,
Platform,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {IS_IOS} from '#/env'
import {
type BottomSheetState,
type BottomSheetViewProps,
@@ -21,11 +22,9 @@ import {
Context as PortalContext,
} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
const NativeView: React.ComponentType<
const NativeView: ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
ref: RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
@@ -36,15 +35,19 @@ const IS_IOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
// older android versions (15 and below) aren't naturally edge-to-edge
// and behave a little differently
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component<
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
ref = createRef<any>()
static contextType = PortalContext
@@ -72,16 +75,12 @@ export class BottomSheetNativeComponent extends React.Component<
this.props.onStateChange?.(event)
}
private updateLayout = () => {
this.ref.current?.updateLayout()
}
static dismissAll = async () => {
await NativeModule.dismissAll()
}
render() {
const Portal = this.context as React.ContextType<typeof PortalContext>
const Portal = this.context as ContextType<typeof PortalContext>
if (!Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
@@ -94,6 +93,7 @@ export class BottomSheetNativeComponent extends React.Component<
let extraStyles
if (IS_IOS15 && this.state.viewHeight) {
const screenHeight = Dimensions.get('screen').height
const {viewHeight} = this.state
const cornerRadius = this.props.cornerRadius ?? 0
if (viewHeight < screenHeight / 2) {
@@ -113,23 +113,14 @@ export class BottomSheetNativeComponent extends React.Component<
nativeViewRef={this.ref}
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
if (IS_IOS15) {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
if (Platform.OS === 'android') {
// TEMP HACKFIX: I had to timebox this, but this is Bad.
// On Android, if you run updateLayout() immediately,
// it will take ages to actually run on the native side.
// However, adding literally any delay will fix this, including
// a console.log() - just sending the log to the CLI is enough.
// TODO: Get to the bottom of this and fix it properly! -sfn
setTimeout(() => this.updateLayout())
} else {
this.updateLayout()
}
}}
onLayout={
IS_IOS15
? e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
: undefined
}
/>
</Portal>
)
@@ -149,13 +140,19 @@ function BottomSheetNativeComponentInner({
onStateChange: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: React.RefObject<View>
onLayout: (event: LayoutChangeEvent) => void
nativeViewRef: RefObject<View>
onLayout?: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions()
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
// for the sheet content
const sheetHeight = IS_NON_E2E_ANDROID
? screenHeight + insets.bottom
: screenHeight - insets.top
return (
<NativeView
+8 -11
View File
@@ -1,20 +1,17 @@
import React from 'react'
import {createContext, useContext, useMemo} from 'react'
import {type ElementType, type ReactNode} from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}>
type PortalContext = ElementType<{children: ReactNode}>
export const Context = React.createContext({} as PortalContext)
export const Context = createContext({} as PortalContext)
Context.displayName = 'BottomSheetPortalContext'
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
export function BottomSheetPortalProvider({children}: {children: ReactNode}) {
const portal = useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])
@@ -32,7 +29,7 @@ const defaultPortal = createPortalGroup_INTERNAL()
export const BottomSheetOutlet = defaultPortal.Outlet
export function BottomSheetProvider({children}: {children: React.ReactNode}) {
export function BottomSheetProvider({children}: {children: ReactNode}) {
return (
<Context.Provider value={defaultPortal.Portal}>
<defaultPortal.Provider>{children}</defaultPortal.Provider>
@@ -1,6 +1,7 @@
import React from 'react'
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {type ReactNode} from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext {
@@ -11,30 +12,29 @@ interface BackgroundNotificationPreferencesContext {
) => void
}
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
const Context = createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext,
)
export const useBackgroundNotificationPreferences = () =>
React.useContext(Context)
export const useBackgroundNotificationPreferences = () => useContext(Context)
export function BackgroundNotificationPreferencesProvider({
children,
}: {
children: React.ReactNode
children: ReactNode
}) {
const [preferences, setPreferences] =
React.useState<BackgroundNotificationHandlerPreferences>({
useState<BackgroundNotificationHandlerPreferences>({
playSoundChat: true,
})
React.useEffect(() => {
useEffect(() => {
;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs)
})()
}, [])
const value = React.useMemo(
const value = useMemo(
() => ({
preferences,
setPref: async <
@@ -1,17 +1,17 @@
import React from 'react'
import {createRef, PureComponent} from 'react'
import {type ComponentType, type RefObject} from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {GifViewProps} from './GifView.types'
import {type GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
> = requireNativeViewManager('ExpoBlueskyGifView')
const NativeView: ComponentType<GifViewProps & {ref: RefObject<any>}> =
requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> {
export class GifView extends PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: React.RefObject<any> = React.createRef()
private nativeRef: RefObject<any> = createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
@@ -1,11 +1,11 @@
import * as React from 'react'
import {createRef, PureComponent} from 'react'
import {type RefObject} from 'react'
import {StyleSheet} from 'react-native'
import {GifViewProps} from './GifView.types'
import {type GifViewProps} from './GifView.types'
export class GifView extends React.PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
React.createRef()
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker', '1.2.3'
s.dependency 'MCEmojiPicker'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
+29 -26
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.117.0",
"version": "1.119.0",
"private": true,
"engines": {
"node": ">=20"
@@ -16,6 +16,13 @@
"expo-image-picker"
]
}
},
"install": {
"exclude": [
"react-native-reanimated",
"@sentry/react-native",
"react-native-pager-view"
]
}
},
"scripts": {
@@ -45,7 +52,7 @@
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:mock-server": "cd dev-env && yarn e2e:mock-server",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
@@ -59,7 +66,7 @@
"intl:extract": "lingui extract --clean --locale en",
"intl:extract:all": "lingui extract --clean",
"intl:compile": "lingui compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.ts ] || yarn intl:compile",
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
@@ -73,13 +80,15 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.21",
"@atproto/api": "^0.19.3",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.7",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.5",
"@expo/webpack-config": "^19.0.1",
@@ -93,17 +102,19 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook-react": "^1.6.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.14.4",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "5.25.0",
@@ -123,15 +134,13 @@
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^20.14.3",
"@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3",
"await-lock": "^2.2.2",
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.27",
@@ -163,14 +172,12 @@
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
@@ -198,20 +205,18 @@
"react-native-compressor": "^1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.1",
"react-native-drawer-layout": "^4.2.2",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "^1.20.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "^4.19.0",
"react-native-screens": "^4.24.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "^0.21.0",
@@ -220,6 +225,7 @@
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
"setimmediate": "^1.0.5",
"sonner": "^2.0.7",
"sonner-native": "^0.21.0",
"tippy.js": "^6.3.7",
@@ -229,14 +235,13 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.209",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@eslint/js": "^9.39.2",
"@expo/config-plugins": "~54.0.1",
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/babel-preset": "0.81.5",
"@react-native/eslint-config": "^0.81.5",
@@ -251,7 +256,6 @@
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"babel-jest": "^29.7.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-preset-expo": "~54.0.0",
@@ -279,10 +283,9 @@
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
"ts-node": "^10.9.1",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.53.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
-10
View File
@@ -1,10 +0,0 @@
diff --git a/node_modules/@lingui/core/dist/index.mjs b/node_modules/@lingui/core/dist/index.mjs
index 9759736..881f67b 100644
--- a/node_modules/@lingui/core/dist/index.mjs
+++ b/node_modules/@lingui/core/dist/index.mjs
@@ -1,4 +1,4 @@
-import unraw from 'unraw';
+import { unraw } from 'unraw';
import { compileMessage } from '@lingui/message-utils/compileMessage';
const isString = (s) => typeof s === "string";
+9 -5
View File
@@ -1,7 +1,8 @@
import '#/logger/sentry/setup'
import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {useEffect, useState} from 'react'
import * as React from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
@@ -11,7 +12,7 @@ import {
import * as ScreenOrientation from 'expo-screen-orientation'
import * as SplashScreen from 'expo-splash-screen'
import * as SystemUI from 'expo-system-ui'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
@@ -19,6 +20,7 @@ import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottom
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
@@ -179,9 +181,11 @@ function InnerApp() {
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
+6 -3
View File
@@ -4,12 +4,13 @@ import './style.css'
import {Fragment, useEffect, useState} from 'react'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
@@ -154,8 +155,10 @@ function InnerApp() {
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
<TranslateOnDeviceProvider>
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
+36 -4
View File
@@ -2,7 +2,7 @@ import {type JSX, useCallback, useRef} from 'react'
import * as Linking from 'expo-linking'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {
type BottomTabBarProps,
createBottomTabNavigator,
@@ -103,6 +103,7 @@ import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacyS
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
@@ -138,7 +139,7 @@ import {
} from '#/components/dialogs/EmailDialog'
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_NATIVE, IS_WEB} from '#/env'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -403,6 +404,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="AutomationLabelSettings"
getComponent={() => AutomationLabelSettingsScreen}
options={{
title: title(msg`Automation Label`),
requireAuth: true,
}}
/>
<Stack.Screen
name="PrivacyAndSecuritySettings"
getComponent={() => PrivacyAndSecuritySettingsScreen}
@@ -685,10 +694,30 @@ function screenOptions(t: Theme) {
function HomeTabNavigator() {
const t = useTheme()
const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS
? ({
headerShown: true,
headerTransparent: true,
headerTitle: '',
headerBackVisible: false,
scrollEdgeEffects: {
top: 'soft',
},
} as const)
: {}
return (
<HomeTab.Navigator screenOptions={screenOptions(t)} initialRouteName="Home">
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
<HomeTab.Screen
name="Home"
getComponent={() => HomeScreen}
options={BLURRED_SCROLL_EDGE_EFFECT}
/>
<HomeTab.Screen
name="Start"
getComponent={() => HomeScreen}
options={BLURRED_SCROLL_EDGE_EFFECT}
/>
{commonScreens(HomeTab as typeof Flat)}
</HomeTab.Navigator>
)
@@ -1008,6 +1037,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
})
}
}
// temp, just testing
void ax.features.enabled(ax.features.AATest)
})
return (
+8 -9
View File
@@ -1,4 +1,5 @@
import React, {useCallback, useEffect} from 'react'
import {forwardRef, useCallback, useEffect, useState} from 'react'
import {type PropsWithChildren} from 'react'
import {
AccessibilityInfo,
Image as RNImage,
@@ -29,7 +30,7 @@ const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
).uri
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
const width = 1000
const height = width * (67 / 64)
return (
@@ -51,19 +52,17 @@ type Props = {
isReady: boolean
}
export function Splash(props: React.PropsWithChildren<Props>) {
export function Splash(props: PropsWithChildren<Props>) {
'use no memo'
const insets = useSafeAreaInsets()
const intro = useSharedValue(0)
const outroLogo = useSharedValue(0)
const outroApp = useSharedValue(0)
const outroAppOpacity = useSharedValue(0)
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
const [isImageLoaded, setIsImageLoaded] = React.useState(false)
const [isLayoutReady, setIsLayoutReady] = React.useState(false)
const [reduceMotion, setReduceMotion] = React.useState<boolean | undefined>(
false,
)
const [isAnimationComplete, setIsAnimationComplete] = useState(false)
const [isImageLoaded, setIsImageLoaded] = useState(false)
const [isLayoutReady, setIsLayoutReady] = useState(false)
const [reduceMotion, setReduceMotion] = useState<boolean | undefined>(false)
const isReady =
props.isReady &&
isImageLoaded &&
@@ -1,8 +1,9 @@
import {useCallback, useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {
SupportCode,
@@ -9,8 +9,9 @@ import {
} from 'react'
import {Dimensions, View} from 'react-native'
import * as Linking from 'expo-linking'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
+13 -16
View File
@@ -1,4 +1,5 @@
import React from 'react'
import {createContext, useCallback, useContext, useMemo, useState} from 'react'
import {type PropsWithChildren} from 'react'
import {type Theme, type ThemeName} from '@bsky.app/alf'
import {
@@ -46,7 +47,7 @@ export type Alf = {
/*
* Context
*/
export const Context = React.createContext<Alf>({
export const Context = createContext<Alf>({
themeName: 'light',
theme: themes.light,
themes,
@@ -64,16 +65,14 @@ Context.displayName = 'AlfContext'
export function ThemeProvider({
children,
theme: themeName,
}: React.PropsWithChildren<{theme: ThemeName}>) {
const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() =>
}: PropsWithChildren<{theme: ThemeName}>) {
const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() =>
getFontScale(),
)
const [fontScaleMultiplier, setFontScaleMultiplier] = React.useState(() =>
const [fontScaleMultiplier, setFontScaleMultiplier] = useState(() =>
computeFontScaleMultiplier(fontScale),
)
const setFontScaleAndPersist = React.useCallback<
Alf['fonts']['setFontScale']
>(
const setFontScaleAndPersist = useCallback<Alf['fonts']['setFontScale']>(
fs => {
setFontScale(fs)
persistFontScale(fs)
@@ -81,12 +80,10 @@ export function ThemeProvider({
},
[setFontScale],
)
const [fontFamily, setFontFamily] = React.useState<Alf['fonts']['family']>(
() => getFontFamily(),
const [fontFamily, setFontFamily] = useState<Alf['fonts']['family']>(() =>
getFontFamily(),
)
const setFontFamilyAndPersist = React.useCallback<
Alf['fonts']['setFontFamily']
>(
const setFontFamilyAndPersist = useCallback<Alf['fonts']['setFontFamily']>(
ff => {
setFontFamily(ff)
persistFontFamily(ff)
@@ -94,7 +91,7 @@ export function ThemeProvider({
[setFontFamily],
)
const value = React.useMemo<Alf>(
const value = useMemo<Alf>(
() => ({
themes,
themeName: themeName,
@@ -122,12 +119,12 @@ export function ThemeProvider({
}
export function useAlf() {
return React.useContext(Context)
return useContext(Context)
}
export function useTheme(theme?: ThemeName) {
const alf = useAlf()
return React.useMemo(() => {
return useMemo(() => {
return theme ? alf.themes[theme] : alf.theme
}, [theme, alf])
}
+2 -2
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useLayoutEffect} from 'react'
import {type ColorSchemeName, useColorScheme} from 'react-native'
import {type ThemeName} from '@bsky.app/alf'
@@ -9,7 +9,7 @@ import {IS_WEB} from '#/env'
export function useColorModeTheme(): ThemeName {
const theme = useThemeName()
React.useLayoutEffect(() => {
useLayoutEffect(() => {
updateDocument(theme)
}, [theme])
+2 -2
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
import * as tokens from '#/alf/tokens'
@@ -52,7 +52,7 @@ export function useGutters([top, right, bottom, left]: Gutter[]) {
bottom = top
left = right
}
return React.useMemo(() => {
return useMemo(() => {
return {
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
paddingRight:
+2
View File
@@ -9,4 +9,6 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
AATest = 'aa-test',
}
+10
View File
@@ -18,6 +18,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return device.get(['nativeSessionId'])
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+10
View File
@@ -21,6 +21,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return window.sessionStorage.getItem(SESSION_ID_KEY)
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+20 -15
View File
@@ -1,4 +1,4 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native'
import {Logger} from '#/logger'
@@ -24,7 +24,7 @@ import {
import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env'
import {useGeolocation} from '#/geolocation'
import {useGeolocationServiceResponse} from '#/geolocation/service'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
@@ -104,7 +104,7 @@ const Context = createContext<AnalyticsBaseContextType>({
referrerSrc: refParams.src,
referrerUrl: refParams.url,
},
geolocation: device.get(['mergedGeolocation']) || {
geolocation: device.get(['geolocationServiceResponse']) || {
countryCode: '',
regionCode: '',
},
@@ -137,7 +137,7 @@ export function AnalyticsContext({
}
}
const sessionId = useSessionId()
const geolocation = useGeolocation()
const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context)
const childContext = useMemo(() => {
const combinedMetadata = {
@@ -181,20 +181,25 @@ export function AnalyticsFeaturesContext({
const parentContext = useContext(Context)
/**
* Side-effect: we need to synchronously set this during the
* same render cycle. It does not trigger a re-render, it just
* sets properties on the singleton GrowthBook instance.
* Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance.
*/
setAttributes(parentContext.metadata)
useEffect(() => {
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
}, [parentContext.metric])
})
feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', {
featureId: feature,
featureResultValue: result.value,
experimentId: result.experiment?.key,
variationId: result.experimentResult?.key,
})
})
const childContext = useMemo<AnalyticsContextType>(() => {
return {
+3 -1
View File
@@ -4,6 +4,7 @@ import {Logger} from '#/logger'
import * as env from '#/env'
type Event<M extends Record<string, any>> = {
source: 'app'
time: number
event: keyof M
payload: M[keyof M]
@@ -43,7 +44,8 @@ export class MetricsClient<M extends Record<string, any>> {
) {
this.start()
const e = {
const e: Event<M> = {
source: 'app',
time: Date.now(),
event,
payload,
+54
View File
@@ -2,6 +2,8 @@
* Do not import runtime code into this file
*/
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -15,6 +17,14 @@ export type Events = {
experimentId: string
variationId: string
}
'feature:viewed': {
featureId: string
featureResultValue: unknown
/** Only available if feature has experiment rules applied */
experimentId?: string
/** Only available if feature has experiment rules applied */
variationId?: string
}
'account:loggedIn': {
logContext:
@@ -563,6 +573,10 @@ export type Events = {
profilesCount: number
feedsCount: number
}
'starterPack:convertToList': {
starterPack: string
memberCount: number
}
'starterPack:ctaPress': {
starterPack: string
}
@@ -635,6 +649,32 @@ export type Events = {
tab: string
}
'search:query': {
source: 'typed' | 'history' | 'autocomplete'
}
'search:results:loaded': {
tab: 'top' | 'latest' | 'people' | 'feeds'
initialCount: number
}
'search:result:press': {
tab?: 'top' | 'latest' | 'people' | 'feeds'
resultType: 'post' | 'profile' | 'feed'
position: number
uri: string
}
'search:recent:press': {
profileDid: string
position: number
}
'search:autocomplete:press': {
profileDid: string
position: number
}
'progressGuide:hide': {}
'progressGuide:followDialog:open': {}
@@ -667,6 +707,17 @@ export type Events = {
targetLanguage: string
textLength: number
}
'translate:result': {
method: 'on-device' | 'google-translate' | 'fallback-alert'
os: Platform['OS']
sourceLanguage: string | null
targetLanguage: string
}
'translate:override': {
os: Platform['OS']
sourceLanguage: string
targetLanguage: string
}
'verification:create': {}
'verification:revoke': {}
@@ -681,6 +732,9 @@ export type Events = {
'verification:settings:hideBadges': {}
'verification:settings:unHideBadges': {}
'bot:label:toggle': {state: 'add' | 'remove'}
'bot:badge:click': {}
'live:create': {duration: number}
'live:edit': {}
'live:remove': {}
+12 -14
View File
@@ -1,8 +1,9 @@
import React, {useCallback} from 'react'
import {Fragment, useCallback} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -15,9 +16,8 @@ import {Button} from '#/components/Button'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useActorStatus} from '#/features/liveNow'
export function AccountList({
@@ -52,7 +52,7 @@ export function AccountList({
t.atoms.border_contrast_low,
]}>
{accounts.map(account => (
<React.Fragment key={account.did}>
<Fragment key={account.did}>
<AccountItem
profile={profiles?.profiles.find(p => p.did === account.did)}
account={account}
@@ -61,7 +61,7 @@ export function AccountList({
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
</React.Fragment>
</Fragment>
))}
<Button
testID="chooseAddAccountBtn"
@@ -115,7 +115,6 @@ function AccountItem({
}) {
const t = useTheme()
const {_} = useLingui()
const verification = useSimpleVerificationState({profile})
const {isActive: live} = useActorStatus(profile)
const onPress = useCallback(() => {
@@ -163,13 +162,12 @@ function AccountItem({
profile?.displayName || profile?.handle || account.handle,
)}
</Text>
{verification.showBadge && (
<View>
<VerificationCheck
width={12}
verifier={verification.role === 'verifier'}
/>
</View>
{profile && (
<ProfileBadges
profile={profile}
size="sm"
style={[{marginTop: -2}]}
/>
)}
</View>
<Text
+2 -6
View File
@@ -9,10 +9,6 @@ import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons
import {Text as BaseText, type TextProps} from '#/components/Typography'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from './icons/Emoji'
export const colors = {
warning: '#FFC404',
}
type Context = {
type: 'info' | 'tip' | 'warning' | 'error' | 'apology'
}
@@ -35,7 +31,7 @@ export function Icon() {
const fill = {
info: t.atoms.text_contrast_medium.color,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.text_contrast_medium.color,
}[type]
@@ -110,7 +106,7 @@ export function Outer({
const borderColor = {
info: t.atoms.border_contrast_high.borderColor,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.border_contrast_high.borderColor,
}[type]
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useCallback} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -20,7 +20,7 @@ export function AppLanguageDropdown() {
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
const onChangeAppLanguage = useCallback(
(value: string) => {
if (!value) return
if (sanitizedLang !== value) {
+79
View File
@@ -0,0 +1,79 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Bot_Filled as RobotIcon} from '#/components/icons/Bot'
import {Text} from '#/components/Typography'
import {navigate} from '#/Navigation'
import type * as bsky from '#/types/bsky'
export function BotAccountAlert({
control,
profile,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
}) {
const {t: l} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const isSelf = profile.did === currentAccount?.did
const description = isSelf
? l`You have marked this account as automated. You can remove it at any time from your account settings.`
: l`This account has been marked as automated by its owner.`
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.ScrollableInner
label={l`Automated account`}
style={[web({maxWidth: 320})]}>
<View style={[a.align_center, a.pb_md, a.shadow_sm]}>
<RobotIcon width={48} fill={t.atoms.text_contrast_medium.color} />
</View>
<View style={[a.align_center]}>
<Text
style={[
a.leading_snug,
a.text_center,
a.pb_xl,
a.text_md,
t.atoms.text_contrast_high,
{maxWidth: 300},
]}>
{description}
</Text>
</View>
<View style={[a.w_full, a.gap_sm]}>
<Button
label={l`Okay`}
onPress={() => control.close()}
color="primary"
size="large">
<ButtonText>
<Trans>Okay</Trans>
</ButtonText>
</Button>
{isSelf ? (
<Button
label={l`Open settings`}
onPress={() => {
control.close(() => {
navigate('AutomationLabelSettings')
})
}}
color="secondary"
size="large">
<ButtonText>
<Trans>Open settings</Trans>
</ButtonText>
</Button>
) : null}
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+92
View File
@@ -0,0 +1,92 @@
import {View} from 'react-native'
import {type ComAtprotoLabelDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {BotAccountAlert} from '#/components/BotAccountAlert'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Bot_Filled as RobotIcon} from '#/components/icons/Bot'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function isBotAccount(profile: {
did: string
labels?: ComAtprotoLabelDefs.Label[]
}): boolean {
return (
profile.labels?.some(l => l.val === 'bot' && l.src === profile.did) ?? false
)
}
export function BotBadge({
profile,
alwaysShow = false,
width,
}: {
profile: bsky.profile.AnyProfileView
alwaysShow?: boolean
width: number
}) {
const t = useTheme()
if (!isBotAccount(profile) && !alwaysShow) {
return null
}
return (
<View>
<RobotIcon width={width} fill={t.atoms.text_contrast_medium.color} />
</View>
)
}
export function BotBadgeButton({
profile,
width,
}: {
profile: bsky.profile.AnyProfileView
width: number
}) {
const t = useTheme()
const ax = useAnalytics()
const {t: l} = useLingui()
const control = useDialogControl()
if (!isBotAccount(profile)) {
return null
}
return (
<>
<Button
label={l`Automated account`}
hitSlop={20}
onPress={evt => {
evt.preventDefault()
ax.metric('bot:badge:click', {})
control.open()
}}>
{({hovered}) => (
<View
style={[
a.justify_end,
a.align_end,
a.transition_transform,
{
width: width,
height: width,
transform: [{scale: hovered ? 1.1 : 1}],
},
]}>
<RobotIcon
width={width}
fill={t.atoms.text_contrast_medium.color}
/>
</View>
)}
</Button>
<BotAccountAlert control={control} profile={profile} />
</>
)
}
+84 -77
View File
@@ -1,4 +1,12 @@
import React from 'react'
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import {type ComponentType, type ReactElement, type ReactNode} from 'react'
import {
type AccessibilityProps,
type GestureResponderEvent,
@@ -75,8 +83,8 @@ export type ButtonState = {
export type ButtonContext = VariantProps & ButtonState
type NonTextElements =
| React.ReactElement<any>
| Iterable<React.ReactElement<any> | null | undefined | boolean>
| ReactElement<any>
| Iterable<ReactElement<any> | null | undefined | boolean>
export type ButtonProps = Pick<
PressableProps,
@@ -102,13 +110,13 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
PressableComponent?: ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps &
VariantProps & {disabled?: boolean; emoji?: boolean}
const Context = React.createContext<VariantProps & ButtonState>({
const Context = createContext<VariantProps & ButtonState>({
hovered: false,
focused: false,
pressed: false,
@@ -117,10 +125,10 @@ const Context = React.createContext<VariantProps & ButtonState>({
Context.displayName = 'ButtonContext'
export function useButtonContext() {
return React.useContext(Context)
return useContext(Context)
}
export const Button = React.forwardRef<View, ButtonProps>(
export const Button = forwardRef<View, ButtonProps>(
(
{
children,
@@ -153,13 +161,13 @@ export const Button = React.forwardRef<View, ButtonProps>(
}
const t = useTheme()
const [state, setState] = React.useState({
const [state, setState] = useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(
const onPressIn = useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
@@ -169,7 +177,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressInOuter],
)
const onPressOut = React.useCallback(
const onPressOut = useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
@@ -179,7 +187,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onPressOutOuter],
)
const onHoverIn = React.useCallback(
const onHoverIn = useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
@@ -189,7 +197,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverInOuter],
)
const onHoverOut = React.useCallback(
const onHoverOut = useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
@@ -199,7 +207,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onHoverOutOuter],
)
const onFocus = React.useCallback(
const onFocus = useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
@@ -209,7 +217,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
},
[setState, onFocusOuter],
)
const onBlur = React.useCallback(
const onBlur = useCallback(
(e: NativeSyntheticEvent<TargetedEvent>) => {
setState(s => ({
...s,
@@ -220,7 +228,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
[setState, onBlurOuter],
)
const {baseStyles, hoverStyles} = React.useMemo(() => {
const {baseStyles, hoverStyles} = useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
@@ -526,7 +534,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
}
}, [t, variant, color, size, shape, disabled])
const context = React.useMemo<ButtonContext>(
const context = useMemo<ButtonContext>(
() => ({
...state,
variant,
@@ -581,7 +589,7 @@ Button.displayName = 'Button'
export function useSharedButtonTextStyles() {
const t = useTheme()
const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => {
return useMemo(() => {
const baseStyles: TextStyle[] = []
/*
@@ -769,7 +777,7 @@ export function ButtonIcon({
icon: Comp,
size,
}: {
icon: React.ComponentType<SVGIconProps>
icon: ComponentType<SVGIconProps>
/**
* @deprecated no longer needed
*/
@@ -778,67 +786,66 @@ export function ButtonIcon({
}) {
const {size: buttonSize, shape: buttonShape} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
const {iconSize, iconContainerSize, iconNegativeMargin} =
React.useMemo(() => {
/**
* Pre-set icon sizes for different button sizes
*/
const iconSizeShorthand =
size ??
(({
large: 'md',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
SVGIconProps['size'],
undefined
>)
const {iconSize, iconContainerSize, iconNegativeMargin} = useMemo(() => {
/**
* Pre-set icon sizes for different button sizes
*/
const iconSizeShorthand =
size ??
(({
large: 'md',
small: 'sm',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
SVGIconProps['size'],
undefined
>)
/*
* Copied here from icons/common.tsx so we can tweak if we need to, but
* also so that we can calculate transforms.
*/
const iconSize = {
xs: 12,
sm: 16,
md: 18,
lg: 24,
xl: 28,
'2xs': 8,
'2xl': 32,
'3xl': 40,
}[iconSizeShorthand]
/*
* Copied here from icons/common.tsx so we can tweak if we need to, but
* also so that we can calculate transforms.
*/
const iconSize = {
xs: 12,
sm: 16,
md: 18,
lg: 24,
xl: 28,
'2xs': 8,
'2xl': 32,
'3xl': 40,
}[iconSizeShorthand]
/*
* Goal here is to match rendered text size so that different size icons
* don't increase button size
*/
const iconContainerSize = {
large: 20,
small: 17,
tiny: 15,
/*
* Goal here is to match rendered text size so that different size icons
* don't increase button size
*/
const iconContainerSize = {
large: 20,
small: 17,
tiny: 15,
}[buttonSize || 'small']
/*
* The icon needs to be closer to the edge of the button than the text. Therefore
* we make the gap slightly too large, and then pull in the sides using negative margins.
*/
let iconNegativeMargin = 0
if (buttonShape === 'default') {
iconNegativeMargin = {
large: -2,
small: -2,
tiny: -1,
}[buttonSize || 'small']
}
/*
* The icon needs to be closer to the edge of the button than the text. Therefore
* we make the gap slightly too large, and then pull in the sides using negative margins.
*/
let iconNegativeMargin = 0
if (buttonShape === 'default') {
iconNegativeMargin = {
large: -2,
small: -2,
tiny: -1,
}[buttonSize || 'small']
}
return {
iconSize,
iconContainerSize,
iconNegativeMargin,
}
}, [buttonSize, buttonShape, size])
return {
iconSize,
iconContainerSize,
iconNegativeMargin,
}
}, [buttonSize, buttonShape, size])
return (
<View
@@ -888,8 +895,8 @@ export type StackedButtonProps = Omit<
keyof VariantProps | 'children'
> &
Pick<VariantProps, 'color'> & {
children: React.ReactNode
icon: React.ComponentType<SVGIconProps>
children: ReactNode
icon: ComponentType<SVGIconProps>
}
export function StackedButton({children, ...props}: StackedButtonProps) {
+1 -1
View File
@@ -7,7 +7,7 @@ import Animated, {
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
+1 -1
View File
@@ -5,7 +5,7 @@ import Animated, {
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
+7 -7
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {createContext, useContext} from 'react'
import {
type ContextType,
@@ -6,17 +6,17 @@ import {
type MenuContextType,
} from '#/components/ContextMenu/types'
export const Context = React.createContext<ContextType | null>(null)
export const Context = createContext<ContextType | null>(null)
Context.displayName = 'ContextMenuContext'
export const MenuContext = React.createContext<MenuContextType | null>(null)
export const MenuContext = createContext<MenuContextType | null>(null)
MenuContext.displayName = 'ContextMenuMenuContext'
export const ItemContext = React.createContext<ItemContextType | null>(null)
export const ItemContext = createContext<ItemContextType | null>(null)
ItemContext.displayName = 'ContextMenuItemContext'
export function useContextMenuContext() {
const context = React.useContext(Context)
const context = useContext(Context)
if (!context) {
throw new Error(
@@ -28,7 +28,7 @@ export function useContextMenuContext() {
}
export function useContextMenuMenuContext() {
const context = React.useContext(MenuContext)
const context = useContext(MenuContext)
if (!context) {
throw new Error(
@@ -40,7 +40,7 @@ export function useContextMenuMenuContext() {
}
export function useContextMenuItemContext() {
const context = React.useContext(ItemContext)
const context = useContext(ItemContext)
if (!context) {
throw new Error(
+99 -35
View File
@@ -1,4 +1,7 @@
import React, {
import {
cloneElement,
Fragment,
isValidElement,
useCallback,
useEffect,
useId,
@@ -6,6 +9,7 @@ import React, {
useRef,
useState,
} from 'react'
import {type ReactNode} from 'react'
import {
BackHandler,
Keyboard,
@@ -23,6 +27,7 @@ import {
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import {KeyboardEvents} from 'react-native-keyboard-controller'
import Animated, {
clamp,
interpolate,
@@ -35,12 +40,13 @@ import Animated, {
type WithSpringConfig,
} from 'react-native-reanimated'
import {
type EdgeInsets,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {captureRef} from 'react-native-view-shot'
import {Image, type ImageErrorEventData} from 'expo-image'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native'
import flattenReactChildren from 'react-keyed-flatten-children'
@@ -81,9 +87,9 @@ export {
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
const SPRING_IN: WithSpringConfig = {
mass: IS_IOS ? 1.25 : 0.75,
damping: 50,
stiffness: 1100,
mass: 0.75,
damping: 300,
stiffness: 1200,
restDisplacementThreshold: 0.01,
}
@@ -97,7 +103,7 @@ const SPRING_OUT: WithSpringConfig = {
/**
* Needs placing near the top of the provider stack, but BELOW the theme provider.
*/
export function Provider({children}: {children: React.ReactNode}) {
export function Provider({children}: {children: ReactNode}) {
return (
<PortalProvider>
{children}
@@ -106,10 +112,11 @@ export function Provider({children}: {children: React.ReactNode}) {
)
}
export function Root({children}: {children: React.ReactNode}) {
export function Root({children}: {children: ReactNode}) {
const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null)
const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
const isFocused = useIsFocused()
@@ -142,6 +149,7 @@ export function Root({children}: {children: React.ReactNode}) {
({
isOpen: !!measurement && isFocused,
measurement,
returnLocationSV,
animationSV,
translationSV,
mode,
@@ -149,6 +157,8 @@ export function Root({children}: {children: React.ReactNode}) {
setMeasurement(evt)
setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
// reset return location
returnLocationSV.set(null)
},
close: () => {
animationSV.set(
@@ -156,6 +166,9 @@ export function Root({children}: {children: React.ReactNode}) {
if (finished) {
hoverablesSV.set({})
translationSV.set(0)
// note: return location has to be reset on open,
// rather than on close, otherwise there's a flicker
// where the reanimated update is faster than the react render
runOnJS(onCompletedClose)()
}
}),
@@ -194,6 +207,7 @@ export function Root({children}: {children: React.ReactNode}) {
}) satisfies ContextType,
[
measurement,
returnLocationSV,
setMeasurement,
onCompletedClose,
isFocused,
@@ -225,7 +239,7 @@ export function Root({children}: {children: React.ReactNode}) {
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const context = useContextMenuContext()
const playHaptic = useHaptics()
const {top: topInset} = useSafeAreaInsets()
const insets = useSafeAreaInsets()
const ref = useRef<View>(null)
const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null)
@@ -237,23 +251,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const open = useNonReactiveCallback(
async (mode: 'full' | 'auxiliary-only') => {
playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => {
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
measureView(ref.current, insets),
captureRef(ref, {result: 'data-uri'}).catch(err => {
logger.error(err instanceof Error ? err : String(err), {
message: 'Failed to capture image of context menu trigger',
@@ -262,16 +261,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
return '<failed capture>'
}),
])
Keyboard.dismiss()
setImage(capture)
setPendingMeasurement({measurement, mode})
if (measurement) {
setPendingMeasurement({measurement, mode})
}
},
)
// after keyboard hides, the position might change - set a return location
useEffect(() => {
if (context.isOpen && context.measurement) {
const hide = KeyboardEvents.addListener('keyboardDidHide', () => {
measureView(ref.current, insets)
.then(newMeasurement => {
if (!newMeasurement || !context.measurement) return
if (
newMeasurement.x !== context.measurement.x ||
newMeasurement.y !== context.measurement.y
) {
context.returnLocationSV.set({
x: newMeasurement.x,
y: newMeasurement.y,
})
}
})
.catch(() => {})
})
return () => {
hide.remove()
}
}
}, [context, insets])
const doubleTapGesture = useMemo(() => {
return Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(() => open('auxiliary-only'))
.onEnd(() => void open('auxiliary-only'))
.runOnJS(true)
}, [open])
@@ -360,6 +388,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
animation={animationSV}
image={image}
measurement={measurement}
returnLocation={context.returnLocationSV}
onDisplay={() => {
if (pendingMeasurement) {
context.open(
@@ -384,6 +413,7 @@ function TriggerClone({
animation,
image,
measurement,
returnLocation,
onDisplay,
label,
}: {
@@ -391,14 +421,29 @@ function TriggerClone({
animation: SharedValue<number>
image: string
measurement: Measurement
returnLocation: SharedValue<{x: number; y: number} | null>
onDisplay: () => void
label: string
}) {
const {_} = useLingui()
const animatedStyles = useAnimatedStyle(() => ({
transform: [{translateY: translation.get() * animation.get()}],
}))
const animatedStyles = useAnimatedStyle(() => {
const anim = animation.get()
const ret = returnLocation.get()
const returnOffsetX = ret
? interpolate(anim, [0, 1], [ret.x - measurement.x, 0])
: 0
const returnOffsetY = ret
? interpolate(anim, [0, 1], [ret.y - measurement.y, 0])
: 0
return {
transform: [
{translateX: returnOffsetX},
{translateY: translation.get() * anim + returnOffsetY},
],
}
})
const handleError = useCallback(
(evt: ImageErrorEventData) => {
@@ -528,7 +573,7 @@ export function Outer({
style,
align = 'left',
}: {
children: React.ReactNode
children: ReactNode
style?: StyleProp<ViewStyle>
align?: 'left' | 'right'
}) {
@@ -648,22 +693,22 @@ export function Outer({
t.atoms.border_contrast_low,
]}>
{flattenReactChildren(children).map((child, i) => {
return React.isValidElement(child) &&
return isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
<Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{React.cloneElement(child, {
{cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
</Fragment>
) : null
})}
</View>
@@ -851,7 +896,7 @@ export function ItemRadio({selected}: {selected: boolean}) {
)
}
export function LabelText({children}: {children: React.ReactNode}) {
export function LabelText({children}: {children: ReactNode}) {
const t = useTheme()
return (
<Text
@@ -874,6 +919,25 @@ export function Divider() {
)
}
function measureView(view: View | null, insets: EdgeInsets) {
if (!view) return Promise.resolve(null)
return new Promise<Measurement>(resolve => {
view?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: insets.top, // not included in measurement
}),
width,
height,
}),
)
})
}
function getHoveredHoverable(
evt:
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
+1
View File
@@ -49,6 +49,7 @@ export type ContextType = {
translationSV: SharedValue<number>
mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
returnLocationSV: SharedValue<{x: number; y: number} | null>
close: () => void
registerHoverable: (
id: string,
+1 -1
View File
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
export const Context = createContext<DialogContextProps>({
close: () => {},
IS_NATIVEDialog: false,
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
+100 -74
View File
@@ -1,27 +1,32 @@
import React, {useImperativeHandle} from 'react'
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Keyboard,
type KeyboardEventListener,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
type ScrollView,
ScrollView,
type StyleProp,
TextInput,
View,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
import Animated, {
runOnJS,
type ScrollEvent,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {ScrollProvider} from '#/lib/ScrollContext'
@@ -29,7 +34,7 @@ import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
@@ -38,7 +43,8 @@ import {
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {IS_ANDROID, IS_IOS} from '#/env'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
type BottomSheetSnapPointChangeEvent,
@@ -62,21 +68,21 @@ export function Outer({
}: React.PropsWithChildren<DialogOuterProps>) {
const themeName = useThemeName()
const t = useTheme(themeName)
const ref = React.useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const ref = useRef<BottomSheetNativeComponent>(null)
const closeCallbacks = useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
const prevSnapPoint = useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
)
const [disableDrag, setDisableDrag] = React.useState(false)
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
const [disableDrag, setDisableDrag] = useState(false)
const [snapPoint, setSnapPoint] = useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
const callQueuedCallbacks = React.useCallback(() => {
const callQueuedCallbacks = useCallback(() => {
for (const cb of closeCallbacks.current) {
try {
cb()
@@ -88,7 +94,7 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(() => {
const open = useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
@@ -96,7 +102,7 @@ export function Outer({
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
const close = useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
@@ -105,7 +111,7 @@ export function Outer({
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
// happen before we run this. It is passed to the `BottomSheet` component.
const onCloseAnimationComplete = React.useCallback(() => {
const onCloseAnimationComplete = useCallback(() => {
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
@@ -151,10 +157,10 @@ export function Outer({
[open, close],
)
const context = React.useMemo(
const context = useMemo(
() => ({
close,
IS_NATIVEDialog: true,
isNativeDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
@@ -166,7 +172,8 @@ export function Outer({
return (
<BottomSheet
ref={ref}
cornerRadius={20}
// device-bezel radius when undefined
cornerRadius={IS_LIQUID_GLASS ? undefined : 20}
backgroundColor={t.atoms.bg.backgroundColor}
{...nativeOptions}
onSnapPointChange={onSnapPointChange}
@@ -181,6 +188,9 @@ export function Outer({
)
}
/**
* @deprecated use `Dialog.ScrollableInner` instead
*/
export function Inner({children, style, header}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
@@ -190,9 +200,9 @@ export function Inner({children, style, header}: DialogInnerProps) {
style={[
a.pt_2xl,
a.px_xl,
{
paddingBottom: insets.bottom + insets.top,
},
IS_LIQUID_GLASS
? a.pb_2xl
: {paddingBottom: insets.bottom + insets.top},
style,
]}>
{children}
@@ -201,41 +211,23 @@ export function Inner({children, style, header}: DialogInnerProps) {
)
}
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler(
{
onEnd: e => {
'worklet'
runOnJS(setKeyboardHeight)(e.height)
},
},
[],
const [keyboardHeight, setKeyboardHeight] = useState(() =>
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
)
let paddingBottom = 0
if (IS_IOS) {
paddingBottom += keyboardHeight / 4
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.bottom + tokens.space.md
}
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else {
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top
}
paddingBottom +=
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
}
const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
setKeyboardHeight(e.endCoordinates.height)
}, [])
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!IS_ANDROID) {
@@ -250,20 +242,33 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
return (
<KeyboardAwareScrollView
<ScrollView
contentContainerStyle={[
a.pt_2xl,
a.px_xl,
{paddingBottom},
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
onScroll={IS_ANDROID ? onScroll : undefined}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
@@ -271,22 +276,27 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</KeyboardAwareScrollView>
</ScrollView>
)
},
)
export const InnerFlatList = React.forwardRef<
export const InnerFlatList = forwardRef<
ListMethods,
ListProps<any> & {
webInnerStyle?: StyleProp<ViewStyle>
webInnerContentContainerStyle?: StyleProp<ViewStyle>
footer?: React.ReactNode
}
>(function InnerFlatList({footer, style, ...props}, ref) {
>(function InnerFlatList(
{headerOffset, footer, style, contentContainerStyle, ...props},
ref,
) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!IS_ANDROID) {
@@ -301,24 +311,44 @@ export const InnerFlatList = React.forwardRef<
}
return (
<ScrollProvider onScroll={onScroll}>
<ScrollProvider
onScroll={onScroll}
onEndDrag={onScroll}
onMomentumEnd={onScroll}>
<List
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
scrollIndicatorInsets={{top: headerOffset}}
bounces={isAtMaxSnapPoint}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
style={[a.h_full, style]}
contentContainerStyle={[
{paddingTop: headerOffset},
android({
paddingBottom: insets.top + insets.bottom + tokens.space.xl,
}),
contentContainerStyle,
]}
/>
{footer}
</ScrollProvider>
)
})
export function FlatListFooter({children}: {children: React.ReactNode}) {
export function FlatListFooter({
children,
onLayout,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
const {top, bottom} = useSafeAreaInsets()
const {bottom} = useSafeAreaInsets()
const {height} = useReanimatedKeyboardAnimation()
const animatedStyle = useAnimatedStyle(() => {
@@ -330,6 +360,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
return (
<Animated.View
onLayout={onLayout}
style={[
a.absolute,
a.bottom_0,
@@ -340,12 +371,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
t.atoms.border_contrast_low,
a.px_lg,
a.pt_md,
{
paddingBottom: platform({
ios: tokens.space.md + bottom,
android: tokens.space.md + bottom + top,
}),
},
{paddingBottom: bottom + tokens.space.md},
// TODO: had to admit defeat here, but we should
// try and get this to work for Android as well -sfn
ios(animatedStyle),
+29 -13
View File
@@ -1,14 +1,23 @@
import React, {useImperativeHandle} from 'react'
import {
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import {type PropsWithChildren, type ReactNode} from 'react'
import {
FlatList,
type FlatListProps,
type GestureResponderEvent,
type LayoutChangeEvent,
Pressable,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
import {RemoveScrollBar} from 'react-remove-scroll-bar'
@@ -44,18 +53,18 @@ export function Outer({
control,
onClose,
webOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
}: PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isOpen, setIsOpen] = useState(false)
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback(() => {
const open = useCallback(() => {
setDialogIsOpen(control.id, true)
setIsOpen(true)
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback<DialogControlProps['close']>(
const close = useCallback<DialogControlProps['close']>(
cb => {
setDialogIsOpen(control.id, false)
setIsOpen(false)
@@ -79,7 +88,7 @@ export function Outer({
[control.id, onClose, setDialogIsOpen],
)
const handleBackgroundPress = React.useCallback(
const handleBackgroundPress = useCallback(
async (e: GestureResponderEvent) => {
webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
},
@@ -95,10 +104,10 @@ export function Outer({
[close, open],
)
const context = React.useMemo(
const context = useMemo(
() => ({
close,
IS_NATIVEDialog: false,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
@@ -164,7 +173,7 @@ export function Inner({
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
const {close} = React.useContext(Context)
const {close} = useContext(Context)
const {gtMobile} = useBreakpoints()
const {reduceMotionEnabled} = useA11y()
FocusGuards.useFocusGuards()
@@ -214,7 +223,7 @@ export function Inner({
export const ScrollableInner = Inner
export const InnerFlatList = React.forwardRef<
export const InnerFlatList = forwardRef<
FlatList,
FlatListProps<any> & {label: string} & {
webInnerStyle?: StyleProp<ViewStyle>
@@ -253,11 +262,18 @@ export const InnerFlatList = React.forwardRef<
)
})
export function FlatListFooter({children}: {children: React.ReactNode}) {
export function FlatListFooter({
children,
onLayout,
}: {
children: ReactNode
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
return (
<View
onLayout={onLayout}
style={[
a.absolute,
a.bottom_0,
@@ -276,7 +292,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
const {close} = useContext(Context)
return (
<View
style={[
+11 -4
View File
@@ -8,6 +8,7 @@ import {
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS} from '#/env'
export function Header({
renderLeft,
@@ -35,7 +36,7 @@ export function Header({
a.flex_row,
a.justify_center,
a.align_center,
{minHeight: 50},
{minHeight: IS_LIQUID_GLASS ? 64 : 50},
a.border_b,
t.atoms.border_contrast_medium,
t.atoms.bg,
@@ -44,11 +45,15 @@ export function Header({
style,
]}>
{renderLeft && (
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
<View style={[a.absolute, {left: IS_LIQUID_GLASS ? 12 : 6}]}>
{renderLeft()}
</View>
)}
{children}
{renderRight && (
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
<View style={[a.absolute, {right: IS_LIQUID_GLASS ? 12 : 6}]}>
{renderRight()}
</View>
)}
</View>
)
@@ -62,7 +67,9 @@ export function HeaderText({
style?: StyleProp<TextStyle>
}) {
return (
<Text style={[a.text_lg, a.text_center, a.font_semi_bold, style]}>
<Text
style={[a.text_lg, a.text_center, a.font_semi_bold, style]}
maxFontSizeMultiplier={2}>
{children}
</Text>
)
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback} from 'react'
import {SystemBars} from 'react-native-edge-to-edge'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_LIQUID_GLASS} from '#/env'
/**
* If we're calling a system API like the image picker that opens a sheet
@@ -9,7 +9,7 @@ import {IS_IOS} from '#/env'
*/
export function useSheetWrapper() {
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
if (IS_IOS) {
if (IS_IOS && !IS_LIQUID_GLASS) {
const entry = SystemBars.pushStackEntry({
style: {
statusBar: 'light',
+1 -1
View File
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
IS_NATIVEDialog: boolean
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
+2 -2
View File
@@ -1,9 +1,9 @@
import React from 'react'
import {useEffect} from 'react'
import {type DialogControlProps} from '#/components/Dialog/types'
export function useAutoOpen(control: DialogControlProps, showTimeout?: number) {
React.useEffect(() => {
useEffect(() => {
if (showTimeout) {
const timeout = setTimeout(() => {
control.open()
+490
View File
@@ -0,0 +1,490 @@
import {useLayoutEffect, useRef} from 'react'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
type AnimatedRef,
measure,
runOnJS,
scrollTo,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
import {atoms as a, useTheme, web} from '#/alf'
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
import {IS_IOS} from '#/env'
/**
* Drag-to-reorder list. Items are absolutely positioned in a fixed-height
* container and animated via Reanimated shared values on the UI thread.
*
* All positioning is driven by a `slots` map (key → index) and translateY
* (no discrete `top` changes). On drag end the new slot assignment is
* computed on the UI thread first, then React state is updated via runOnJS.
*
* See SortableList.web.tsx for the web implementation using pointer events.
*/
interface SortableListProps<T> {
data: T[]
keyExtractor: (item: T) => string
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
onReorder: (data: T[]) => void
onDragStart?: () => void
onDragEnd?: () => void
/** Fixed row height used for position math. */
itemHeight: number
/** Ref to the parent Animated.ScrollView for auto-scroll. */
scrollRef?: AnimatedRef<Animated.ScrollView>
/** Scroll offset shared value from useScrollViewOffset. */
scrollOffset?: SharedValue<number>
}
const AUTO_SCROLL_THRESHOLD = 50
const AUTO_SCROLL_SPEED = 4
/**
* Bundled into a single shared value so all fields update atomically
* in one set() call on the UI thread.
*/
interface DragState {
/** Maps each item key to its current slot index. */
slots: Record<string, number>
/** Key of the item being dragged, or '' when idle. */
activeKey: string
/** Slot the active item started in. */
dragStartSlot: number
}
export function SortableList<T>({
data,
keyExtractor,
renderItem,
onReorder,
onDragStart,
onDragEnd,
itemHeight,
scrollRef,
scrollOffset,
}: SortableListProps<T>) {
const t = useTheme()
const state = useSharedValue<DragState>({
slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])),
activeKey: '',
dragStartSlot: -1,
})
const dragY = useSharedValue(0)
// Auto-scroll shared values
const scrollCompensation = useSharedValue(0)
const isGestureActive = useSharedValue(false)
// We track scroll position ourselves because scrollOffset.get() lags
// by one frame after scrollTo(), causing a feedback loop where the
// frame callback keeps thinking the item is at the edge.
const trackedScrollY = useSharedValue(0)
// For measuring list position within scroll content
const listRef = useAnimatedRef<Animated.View>()
const listContentOffset = useSharedValue(0)
const viewportHeight = useSharedValue(0)
const measureDone = useSharedValue(false)
// Sync slots when data changes externally (e.g. pin/unpin).
// Skip after our own reorder — the worklet already set correct slots
// on the UI thread, and a redundant JS-side set() would be wasteful.
const skipNextSync = useRef(false)
const currentKeys = data.map(item => keyExtractor(item)).join(',')
useLayoutEffect(() => {
if (skipNextSync.current) {
skipNextSync.current = false
return
}
const nextSlots: Record<string, number> = {}
data.forEach((item, i) => {
nextSlots[keyExtractor(item)] = i
})
state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1})
dragY.set(0)
}, [currentKeys, data, keyExtractor, state, dragY])
const handleReorder = (sortedKeys: string[]) => {
skipNextSync.current = true
const byKey = new Map(data.map(item => [keyExtractor(item), item]))
onReorder(sortedKeys.map(key => byKey.get(key)!))
onDragEnd?.()
}
// Auto-scroll: runs every frame while a gesture is active.
useFrameCallback(() => {
if (!isGestureActive.get()) return
if (!scrollRef || !scrollOffset) return
const s = state.get()
if (s.activeKey === '') return
// Measure list and scroll view on first frame of drag.
// Use scrollOffset here (only once) since no lag has occurred yet.
if (!measureDone.get()) {
const scrollM = measure(
scrollRef as unknown as AnimatedRef<Animated.View>,
)
const listM = measure(listRef)
if (!scrollM || !listM) return
trackedScrollY.set(scrollOffset.get())
listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get())
viewportHeight.set(scrollM.height)
measureDone.set(true)
}
const startSlot = s.dragStartSlot
const currentDragY = dragY.get()
// Use trackedScrollY (not scrollOffset) to avoid the one-frame lag
// after scrollTo() that causes a feedback loop.
const scrollY = trackedScrollY.get()
// Item position relative to scroll viewport top.
const itemContentY =
listContentOffset.get() + startSlot * itemHeight + currentDragY
const itemViewportY = itemContentY - scrollY
const itemBottomViewportY = itemViewportY + itemHeight
let scrollDelta = 0
if (itemViewportY < AUTO_SCROLL_THRESHOLD) {
scrollDelta = -AUTO_SCROLL_SPEED
} else if (
itemBottomViewportY >
viewportHeight.get() - AUTO_SCROLL_THRESHOLD
) {
scrollDelta = AUTO_SCROLL_SPEED
}
if (scrollDelta === 0) return
// Don't scroll if the item is already at a list boundary.
const effectiveSlotPos =
(startSlot * itemHeight + currentDragY) / itemHeight
if (scrollDelta < 0 && effectiveSlotPos <= 0) return
if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return
// Don't scroll past the top.
if (scrollDelta < 0 && scrollY <= 0) return
const newScrollY = Math.max(0, scrollY + scrollDelta)
scrollTo(scrollRef, 0, newScrollY, false)
trackedScrollY.set(newScrollY)
scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY))
})
// Render in stable key order so React never reorders native views.
// On Android, native ViewGroup child reordering causes a visual flash.
const sortedData = [...data].sort((a, b) => {
const ka = keyExtractor(a)
const kb = keyExtractor(b)
return ka < kb ? -1 : ka > kb ? 1 : 0
})
return (
<Animated.View
ref={listRef}
style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}>
{sortedData.map(item => {
const key = keyExtractor(item)
return (
<SortableItem
key={key}
item={item}
itemKey={key}
itemCount={data.length}
itemHeight={itemHeight}
state={state}
dragY={dragY}
scrollCompensation={scrollCompensation}
isGestureActive={isGestureActive}
measureDone={measureDone}
renderItem={renderItem}
onCommitReorder={handleReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
)
})}
</Animated.View>
)
}
function SortableItem<T>({
item,
itemKey,
itemCount,
itemHeight,
state,
dragY,
scrollCompensation,
isGestureActive,
measureDone,
renderItem,
onCommitReorder,
onDragStart,
onDragEnd,
}: {
item: T
itemKey: string
itemCount: number
itemHeight: number
state: Animated.SharedValue<DragState>
dragY: Animated.SharedValue<number>
scrollCompensation: SharedValue<number>
isGestureActive: SharedValue<boolean>
measureDone: SharedValue<boolean>
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
onCommitReorder: (sortedKeys: string[]) => void
onDragStart?: () => void
onDragEnd?: () => void
}) {
const t = useTheme()
const playHaptic = useHaptics()
const lastHapticSlot = useSharedValue(-1)
const gesture = Gesture.Pan()
.onStart(() => {
'worklet'
const s = state.get()
const mySlot = s.slots[itemKey]
state.set({...s, activeKey: itemKey, dragStartSlot: mySlot})
dragY.set(0)
scrollCompensation.set(0)
isGestureActive.set(true)
measureDone.set(false)
lastHapticSlot.set(mySlot)
if (onDragStart) {
runOnJS(onDragStart)()
}
runOnJS(playHaptic)()
})
.onChange(e => {
'worklet'
const startSlot = state.get().dragStartSlot
const minY = -startSlot * itemHeight
const maxY = (itemCount - 1 - startSlot) * itemHeight
// Include scroll compensation so the item tracks with auto-scroll.
const effectiveY = e.translationY + scrollCompensation.get()
const clampedY = Math.max(minY, Math.min(effectiveY, maxY))
dragY.set(clampedY)
const currentSlot = Math.round(
(startSlot * itemHeight + clampedY) / itemHeight,
)
const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
lastHapticSlot.set(clampedSlot)
runOnJS(playHaptic)('Light')
}
})
.onEnd(() => {
'worklet'
// Stop auto-scroll BEFORE the snap animation.
isGestureActive.set(false)
const startSlot = state.get().dragStartSlot
const rawNewSlot = Math.round(
(startSlot * itemHeight + dragY.get()) / itemHeight,
)
const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1))
const snapOffset = (newSlot - startSlot) * itemHeight
// Animate to the target slot, then commit.
dragY.set(
withTiming(snapOffset, {duration: 200}, finished => {
if (finished) {
if (newSlot !== startSlot) {
// Compute new slots on the UI thread so animated styles
// reflect final positions before React re-renders.
const cur = state.get()
const sorted: string[] = new Array(itemCount)
for (const key in cur.slots) {
sorted[cur.slots[key]] = key
}
const movedKey = sorted[startSlot]
sorted.splice(startSlot, 1)
sorted.splice(newSlot, 0, movedKey)
const nextSlots: Record<string, number> = {}
for (let i = 0; i < sorted.length; i++) {
nextSlots[sorted[i]] = i
}
state.set({
slots: nextSlots,
activeKey: '',
dragStartSlot: -1,
})
dragY.set(0)
runOnJS(onCommitReorder)(sorted)
} else {
const s = state.get()
state.set({...s, activeKey: '', dragStartSlot: -1})
dragY.set(0)
if (onDragEnd) {
runOnJS(onDragEnd)()
}
}
}
}),
)
})
// Reset if the gesture is cancelled without onEnd firing.
.onFinalize(() => {
'worklet'
isGestureActive.set(false)
if (state.get().activeKey === itemKey && dragY.get() === 0) {
const s = state.get()
state.set({...s, activeKey: '', dragStartSlot: -1})
if (onDragEnd) {
runOnJS(onDragEnd)()
}
}
})
// All vertical positioning is via translateY (no `top`). This avoids
// discrete jumps when slots change — Reanimated smoothly animates from
// the current translateY to the new target on every state transition.
// On first mount we skip the animation so items appear instantly.
const isFirstRender = useSharedValue(true)
const animatedStyle = useAnimatedStyle(() => {
const s = state.get()
const mySlot = s.slots[itemKey]
if (mySlot === undefined) {
return {}
}
const baseY = mySlot * itemHeight
// Active item: follow the finger with a slight scale-up and shadow.
if (s.activeKey === itemKey) {
return {
transform: [
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
{scale: withSpring(1.03)},
],
zIndex: 999,
...(IS_IOS
? {
shadowColor: '#000',
shadowOffset: {width: 0, height: 1},
shadowOpacity: withSpring(0.08),
shadowRadius: withSpring(4),
}
: {
elevation: withSpring(3),
}),
}
}
// Reset for non-active states. Without this, shadow props
// set during dragging linger on the native view.
const inactive = {
...(IS_IOS
? {
shadowOpacity: withSpring(0),
shadowRadius: withSpring(0),
}
: {
elevation: withSpring(0),
}),
}
// Another item is being dragged — shift to make room.
if (s.activeKey !== '') {
isFirstRender.set(false)
const currentDragPos = Math.round(
(s.dragStartSlot * itemHeight + dragY.get()) / itemHeight,
)
const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1))
let offset = 0
if (
s.dragStartSlot < clampedPos &&
mySlot > s.dragStartSlot &&
mySlot <= clampedPos
) {
offset = -itemHeight
} else if (
s.dragStartSlot > clampedPos &&
mySlot < s.dragStartSlot &&
mySlot >= clampedPos
) {
offset = itemHeight
}
return {
transform: [
{translateY: withTiming(baseY + offset, {duration: 200})},
{scale: withSpring(1)},
],
zIndex: 0,
...inactive,
}
}
// Idle: sit at our slot. On first render use a direct value so items
// don't animate from y=0. After any drag, use withTiming so the
// shift→idle transition is smooth (no discrete jump).
if (isFirstRender.get()) {
isFirstRender.set(false)
return {
transform: [{translateY: baseY}, {scale: 1}],
zIndex: 0,
...inactive,
}
}
return {
transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}],
zIndex: 0,
...inactive,
}
})
const dragHandle = (
<GestureDetector gesture={gesture}>
<Animated.View
testID="feed-drag-handle"
style={[
a.justify_center,
a.align_center,
a.px_sm,
a.py_md,
web({cursor: 'grab'}),
]}
hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={web({pointerEvents: 'none'})}
/>
</Animated.View>
</GestureDetector>
)
return (
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: itemHeight,
},
animatedStyle,
]}>
{renderItem(item, dragHandle)}
</Animated.View>
)
}
+168
View File
@@ -0,0 +1,168 @@
import {useState} from 'react'
import {View} from 'react-native'
import {useTheme} from '#/alf'
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
/**
* Web implementation of SortableList using pointer events.
* See SortableList.tsx for the native version using gesture-handler + Reanimated.
*/
interface SortableListProps<T> {
data: T[]
keyExtractor: (item: T) => string
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
onReorder: (data: T[]) => void
onDragStart?: () => void
onDragEnd?: () => void
/** Fixed row height used for position math. */
itemHeight: number
}
export function SortableList<T>({
data,
keyExtractor,
renderItem,
onReorder,
onDragStart,
onDragEnd,
itemHeight,
}: SortableListProps<T>) {
const t = useTheme()
const [dragState, setDragState] = useState<{
activeIndex: number
currentY: number
startY: number
} | null>(null)
const getNewPosition = (state: {
activeIndex: number
currentY: number
startY: number
}) => {
const translationY = state.currentY - state.startY
const rawNewPos = Math.round(
(state.activeIndex * itemHeight + translationY) / itemHeight,
)
return Math.max(0, Math.min(rawNewPos, data.length - 1))
}
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragState) return
e.preventDefault()
setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null))
}
const handlePointerUp = () => {
if (!dragState) return
const newPos = getNewPosition(dragState)
if (newPos !== dragState.activeIndex) {
const next = [...data]
const [moved] = next.splice(dragState.activeIndex, 1)
next.splice(newPos, 0, moved)
onReorder(next)
}
setDragState(null)
onDragEnd?.()
}
const handlePointerDown = (e: React.PointerEvent, index: number) => {
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY})
onDragStart?.()
}
const newPos = dragState ? getNewPosition(dragState) : -1
return (
<View
style={[
{height: data.length * itemHeight, position: 'relative'},
t.atoms.bg_contrast_25,
]}
// @ts-expect-error web-only pointer events
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}>
{data.map((item, index) => {
const isActive = dragState?.activeIndex === index
// Clamp translation so the item stays within list bounds.
const rawTranslationY = isActive
? dragState.currentY - dragState.startY
: 0
const translationY = isActive
? Math.max(
-index * itemHeight,
Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight),
)
: 0
// Non-dragged items shift to make room for the dragged item.
let offset = 0
if (dragState && !isActive) {
const orig = dragState.activeIndex
if (orig < newPos && index > orig && index <= newPos) {
offset = -itemHeight
} else if (orig > newPos && index < orig && index >= newPos) {
offset = itemHeight
}
}
const dragHandle = (
<div
onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
handlePointerDown(e, index)
}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: 8,
paddingRight: 8,
paddingTop: 12,
paddingBottom: 12,
cursor: isActive ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={{pointerEvents: 'none'} as any}
/>
</div>
)
return (
<View
key={keyExtractor(item)}
style={[
{
position: 'absolute',
top: index * itemHeight,
left: 0,
right: 0,
height: itemHeight,
transform: [{translateY: isActive ? translationY : offset}],
scale: isActive ? 1.03 : 1,
zIndex: isActive ? 999 : 0,
boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
// Animate scale/shadow on pickup, and transform for
// non-dragged items shifting into place.
transition: isActive
? 'box-shadow 200ms ease, scale 200ms ease'
: dragState
? 'transform 200ms ease'
: 'none',
} as any,
]}>
{renderItem(item, dragHandle)}
</View>
)
})}
</View>
)
}
+2 -1
View File
@@ -1,6 +1,7 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {CenteredView} from '#/view/com/util/Views'

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