Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c668fd89 | |||
| 4742b0b8e1 | |||
| 35d3037dee | |||
| fe8e8ce7de | |||
| 8b8acb7bd1 | |||
| fd1e4a0b0b | |||
| 2a018a4c42 | |||
| ab9f2875a0 | |||
| 4f6f710e95 | |||
| 39c3d0647e | |||
| eb5704e70a | |||
| 73f9f686d1 | |||
| d5d3076dda | |||
| 423f1a7ad7 | |||
| 1a487d0943 | |||
| 2a779a7008 | |||
| 3e413e151d | |||
| 13486b0fca | |||
| 443cb9859c | |||
| c4b1561f83 | |||
| bd05fafa27 | |||
| 3dbdca5939 | |||
| 4ad5a55fa6 | |||
| 2a06d67fb4 | |||
| fbb10d8a74 | |||
| c97128c106 | |||
| 7f4c81a837 | |||
| aa1fbf4628 | |||
| 1ae77aa997 | |||
| 7cec490cef | |||
| 1281eb01e6 | |||
| 4339585b27 | |||
| 30c9ea5789 | |||
| afbade6f6f | |||
| 1782a65174 | |||
| 7fd218c97d | |||
| 8446efb739 | |||
| b84f28f884 | |||
| 7dc3b4fa8e | |||
| 37a82761f5 | |||
| 612a778361 | |||
| 162a78e1ae | |||
| c42502fdcb | |||
| 8b1c47a499 | |||
| c2fd87bd8b | |||
| d247a65683 | |||
| a95b877ea3 | |||
| f614bf650d | |||
| 5ee667f307 | |||
| 9c29b2867a | |||
| 9746dd8e4c | |||
| 8e2a5abfff | |||
| 6cdc1fe2b5 | |||
| 9bbcb472ef | |||
| 7c9f05a2af | |||
| d4357b2cb8 | |||
| 576761a645 | |||
| 8a1f8997fe | |||
| 6cb88ce1f3 | |||
| 65c4b7833d | |||
| 9b24d75c9c | |||
| 398df1a4b5 | |||
| db9c2a1596 | |||
| 00816b70dc | |||
| 49272be36b | |||
| 85ffc77983 | |||
| 95bede2dcb | |||
| e656fe688d | |||
| db1f45de92 | |||
| bc61314c41 | |||
| 1850042c37 | |||
| 782edbf281 | |||
| 73b096e443 | |||
| 9b0f0a8d24 | |||
| 9ec06971af | |||
| 75242b9b1f | |||
| 21e53b226d | |||
| 02c2a9ea7c | |||
| cb7e2ab976 | |||
| 067c6dacc9 | |||
| 9b8c46cdb7 | |||
| f3cc850b62 | |||
| 35f9ed82b9 | |||
| 8c4fc087f8 | |||
| f3ddc074a6 | |||
| 5e0ffc0917 | |||
| 3fae42c077 | |||
| 80c0aa35e2 | |||
| 4e3c3e9905 | |||
| 1565e071c1 | |||
| c906fea77a | |||
| 775a041b71 | |||
| 98d17fd950 | |||
| 41b3742365 | |||
| 512ce3d5d0 | |||
| bdf143ce79 | |||
| adc079e095 | |||
| 74330500de | |||
| ac939dc84e | |||
| b12d703353 | |||
| c188fd4f04 | |||
| a1cda126e6 | |||
| ae115ca17e | |||
| 197c069416 | |||
| e67d81f269 | |||
| 7c08bba6cf | |||
| 29bc0f1372 | |||
| 82f42e734c | |||
| 3b9f0b0a68 | |||
| a6ba0810b7 | |||
| 49253dbd88 | |||
| cc2255cc49 | |||
| 8ee9709fbb | |||
| 90d650def2 | |||
| 79e27e303b | |||
| 6be7751769 | |||
| 9b830fd425 | |||
| 4ebeb94b41 | |||
| b6b364700e | |||
| 95f9cc1ed5 | |||
| 06fcea988e | |||
| b166d395d9 | |||
| 1228306309 | |||
| bc001168f3 | |||
| b4cb4c4d13 | |||
| 2e34f965fc | |||
| 9cdfb40ce7 | |||
| c83dddce57 | |||
| 795428971d | |||
| 82fea6da7d | |||
| 2f156bb906 | |||
| c46219bc4d | |||
| 6d68a5bd21 | |||
| cbac380c96 | |||
| 8c4820b1f1 | |||
| 93d2f3a8db | |||
| 3a9526d55e | |||
| 830f305d2d | |||
| 8a088e8710 | |||
| 8cbe3985fa | |||
| 1ec70420e6 | |||
| 63fbf577d9 | |||
| 355868e01e | |||
| 37e5a0f7a4 | |||
| 4356ff0040 | |||
| 4eca13941a | |||
| 8580e4a00e | |||
| 8581285d34 | |||
| 57997d6d16 | |||
| 5459857112 | |||
| 3a28a07081 | |||
| a57faedbae | |||
| a148b2477b |
@@ -46,3 +46,6 @@ GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
# app-config web worker URL
|
||||
APP_CONFIG_DEV_URL=
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ yarn android # Run on Android
|
||||
yarn ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
|
||||
yarn test # Run Jest tests
|
||||
yarn lint # Run ESLint
|
||||
yarn typecheck # Run TypeScript type checking
|
||||
@@ -45,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
|
||||
@@ -59,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.
|
||||
@@ -270,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() {
|
||||
|
||||
@@ -39,7 +39,6 @@ appId: xyz.blueskyweb.app
|
||||
id: "editListNameInput"
|
||||
- eraseText
|
||||
- inputText: "Bad Ppl"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "editListDescriptionInput"
|
||||
- eraseText
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
+23
-9
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
export default {}
|
||||
@@ -1,10 +0,0 @@
|
||||
jest.mock('rn-fetch-blob', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
fs: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
export const DropdownMenu = jest.fn().mockImplementation(() => {})
|
||||
export const create = jest.fn().mockImplementation(() => {})
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -437,6 +443,13 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
'https://www.flickr.com/groups/898944@N23/',
|
||||
'https://www.flickr.com/groups',
|
||||
|
||||
'https://maxblansjaar.bandcamp.com/album/false-comforts',
|
||||
'https://grmnygrmny.bandcamp.com/track/fluid',
|
||||
'https://sufjanstevens.bandcamp.com/',
|
||||
'https://sufjanstevens.bandcamp.com',
|
||||
'https://bandcamp.com/',
|
||||
'https://bandcamp.com',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
@@ -815,6 +828,23 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'bandcamp_album',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
{
|
||||
type: 'bandcamp_track',
|
||||
source: 'bandcamp',
|
||||
playerUri:
|
||||
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
|
||||
+15
-5
@@ -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,6 +259,13 @@ 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,
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>
|
||||
|
After Width: | Height: | Size: 357 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
+1
-1
@@ -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,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}`}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1,10 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
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},
|
||||
) {
|
||||
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')}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+21
-14
@@ -6,21 +6,26 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
|
||||
environment set up. Either follow the top-level README, or something quick
|
||||
like:
|
||||
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
```bash
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
# setup tools and deps (in top level of this repo)
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
# run yarn web dev server, if you wanted
|
||||
yarn web
|
||||
```
|
||||
|
||||
Then build and copy over the big 'ol `bundle.web.js` file:
|
||||
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
|
||||
```bash
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
```
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
```bash
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
# build and output a binary
|
||||
go build -o bskyweb ./cmd/bskyweb/
|
||||
```
|
||||
|
||||
The easiest way to configure the daemon is to copy `example.env` to `.env` and
|
||||
fill in auth values there.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -574,7 +574,10 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.EmbedImages_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasVideo := postView.Embed.EmbedVideo_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
|
||||
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
@@ -582,12 +585,20 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMedia {
|
||||
} else if hasVideo {
|
||||
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
} else if hasMediaImages {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMediaVideo {
|
||||
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -148,11 +148,11 @@
|
||||
</head>
|
||||
<body>
|
||||
{%- block body_all %}
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
<div id="root">
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+1
-1
@@ -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
@@ -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!`;
|
||||
|
||||
@@ -9,6 +9,14 @@ values.
|
||||
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`
|
||||
|
||||
@@ -23,8 +23,6 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party/**',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
|
||||
+2
-2
@@ -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',
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
+2
@@ -48,6 +48,8 @@ class BottomSheetModule : Module() {
|
||||
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
var preventDismiss = false
|
||||
var preventExpansion = false
|
||||
var cornerRadius: CGFloat?
|
||||
var sourceViewTag: Int?
|
||||
var minHeight = 0.0
|
||||
var maxHeight: CGFloat! {
|
||||
didSet {
|
||||
@@ -135,6 +136,15 @@ 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
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ class SheetViewController: UIViewController {
|
||||
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 +49,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,6 +24,7 @@ export interface BottomSheetViewProps {
|
||||
backgroundColor?: ColorValue
|
||||
containerBackgroundColor?: ColorValue
|
||||
disableDrag?: boolean
|
||||
sourceViewTag?: number
|
||||
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
Platform,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -16,10 +17,10 @@ import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
} from './BottomSheet.types'
|
||||
import {BottomSheetPortalProvider} from './BottomSheetPortal'
|
||||
import {Context as PortalContext} from './BottomSheetPortal'
|
||||
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
import {
|
||||
BottomSheetPortalProvider,
|
||||
Context as PortalContext,
|
||||
} from './BottomSheetPortal'
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
@@ -92,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) {
|
||||
@@ -152,6 +154,7 @@ function BottomSheetNativeComponentInner({
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+24
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.117.0",
|
||||
"version": "1.118.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": {
|
||||
@@ -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.20",
|
||||
"@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,10 +102,12 @@
|
||||
"@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",
|
||||
@@ -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",
|
||||
@@ -201,7 +208,6 @@
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -211,7 +217,6 @@
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"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,14 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.208",
|
||||
"@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 +257,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",
|
||||
@@ -281,8 +286,8 @@
|
||||
"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": {
|
||||
|
||||
@@ -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";
|
||||
@@ -1,11 +1,11 @@
|
||||
diff --git a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
|
||||
index c34ba71..13d576a 100644
|
||||
index c34ba71..3602856 100644
|
||||
--- a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
|
||||
+++ b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
|
||||
@@ -159,13 +159,23 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
|
||||
- var totalLines = self.lineHeight == 0.0 ? 0 : Int(ceil(textSize.height / self.lineHeight))
|
||||
-
|
||||
- if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
@@ -27,6 +27,8 @@ index c34ba71..13d576a 100644
|
||||
}
|
||||
|
||||
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
+ finalHeight = ceil(finalHeight)
|
||||
+
|
||||
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
|
||||
+46
-36
@@ -11,7 +11,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,9 +19,14 @@ 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'
|
||||
import {
|
||||
prefetchAppConfig,
|
||||
Provider as AppConfigProvider,
|
||||
} from '#/state/appConfig'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
@@ -103,6 +108,7 @@ if (IS_ANDROID) {
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -174,9 +180,11 @@ function InnerApp() {
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
<TranslateOnDeviceProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
@@ -228,38 +236,40 @@ function App() {
|
||||
*/
|
||||
return (
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
</OnboardingProvider>
|
||||
</KeyboardControllerProvider>
|
||||
</A11yProvider>
|
||||
<AppConfigProvider>
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
</OnboardingProvider>
|
||||
</KeyboardControllerProvider>
|
||||
</A11yProvider>
|
||||
</AppConfigProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+97
-88
@@ -2,17 +2,22 @@ import '#/logger/sentry/setup' // must be near top
|
||||
import '#/view/icons'
|
||||
import './style.css'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
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'
|
||||
import {
|
||||
prefetchAppConfig,
|
||||
Provider as AppConfigProvider,
|
||||
} from '#/state/appConfig'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
@@ -79,9 +84,10 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
@@ -116,68 +122,69 @@ function InnerApp() {
|
||||
})
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</AnalyticsFeaturesContext>
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<TranslateOnDeviceProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</AnalyticsFeaturesContext>
|
||||
</Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</Splash>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
@@ -187,14 +194,14 @@ function InnerApp() {
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return <Splash isReady />
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -203,31 +210,33 @@ function App() {
|
||||
*/
|
||||
return (
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
<AppConfigProvider>
|
||||
<A11yProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
</AppConfigProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+56
-27
@@ -1,8 +1,8 @@
|
||||
import {type JSX, useCallback, useRef} from 'react'
|
||||
import {Linking} from 'react-native'
|
||||
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,
|
||||
@@ -99,6 +99,7 @@ import {SearchScreen} from '#/screens/Search'
|
||||
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
|
||||
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
|
||||
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
|
||||
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
|
||||
import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
@@ -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>
|
||||
)
|
||||
@@ -874,11 +903,6 @@ const LINKING = {
|
||||
},
|
||||
} satisfies LinkingOptions<AllNavigatorParams>
|
||||
|
||||
/**
|
||||
* Used to ensure we don't handle the same notification twice
|
||||
*/
|
||||
let lastHandledNotificationDateDedupe: number | undefined
|
||||
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const ax = useAnalytics()
|
||||
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
|
||||
@@ -889,6 +913,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const previousScreen = useRef<string | undefined>(undefined)
|
||||
const emailDialogControl = useEmailDialogControl()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const linkingUrl = Linking.useLinkingURL()
|
||||
|
||||
/**
|
||||
* Handle navigation to a conversation, or prepares for account switch.
|
||||
@@ -923,29 +948,30 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
)
|
||||
|
||||
async function handlePushNotificationEntry() {
|
||||
function handlePushNotificationEntry() {
|
||||
if (!IS_NATIVE) return
|
||||
|
||||
// deep links take precedence - on android,
|
||||
// getLastNotificationResponseAsync returns a "notification"
|
||||
// that is actually a deep link. avoid handling it twice -sfn
|
||||
if (await Linking.getInitialURL()) {
|
||||
return
|
||||
}
|
||||
// intent urls are handled by `useIntentHandler`
|
||||
if (linkingUrl) return
|
||||
|
||||
/**
|
||||
* The notification that caused the app to open, if applicable
|
||||
*/
|
||||
const response = await Notifications.getLastNotificationResponseAsync()
|
||||
const notificationResponse = Notifications.getLastNotificationResponse()
|
||||
|
||||
if (response) {
|
||||
notyLogger.debug(`handlePushNotificationEntry: response`, {response})
|
||||
if (notificationResponse) {
|
||||
notyLogger.debug(`handlePushNotificationEntry: response`, {
|
||||
response: notificationResponse,
|
||||
})
|
||||
|
||||
if (response.notification.date === lastHandledNotificationDateDedupe)
|
||||
return
|
||||
lastHandledNotificationDateDedupe = response.notification.date
|
||||
// Clear the last notification response to ensure it's not used again
|
||||
try {
|
||||
Notifications.clearLastNotificationResponse()
|
||||
} catch (error) {
|
||||
notyLogger.error(
|
||||
`handlePushNotificationEntry: error clearing notification response`,
|
||||
{error},
|
||||
)
|
||||
}
|
||||
|
||||
const payload = getNotificationPayload(response.notification)
|
||||
const payload = getNotificationPayload(notificationResponse.notification)
|
||||
|
||||
if (payload) {
|
||||
ax.metric('notifications:openApp', {
|
||||
@@ -1011,6 +1037,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// temp, just testing
|
||||
void ax.features.enabled(ax.features.AATest)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
+82
-14
@@ -4,26 +4,94 @@
|
||||
* the app is ready to go.
|
||||
*/
|
||||
|
||||
import {View} from 'react-native'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
|
||||
const size = 100
|
||||
const ratio = 57 / 64
|
||||
|
||||
export function Splash() {
|
||||
export function Splash({
|
||||
isReady,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
isReady: boolean
|
||||
}>) {
|
||||
const [isAnimationComplete, setIsAnimationComplete] = useState(false)
|
||||
const splashRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// hide the static one that's baked into the HTML - gets replaced by our React version below
|
||||
useEffect(() => {
|
||||
// double rAF ensures that the React version gets painted first
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const splash = document.getElementById('splash')
|
||||
if (splash) {
|
||||
splash.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// when ready, we fade/scale out
|
||||
useEffect(() => {
|
||||
if (!isReady) return
|
||||
|
||||
const reduceMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches
|
||||
const node = splashRef.current
|
||||
if (!node || reduceMotion) {
|
||||
setIsAnimationComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
const animation = node.animate(
|
||||
[
|
||||
{opacity: 1, transform: 'scale(1)'},
|
||||
{opacity: 0, transform: 'scale(1.5)'},
|
||||
],
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
fill: 'forwards',
|
||||
},
|
||||
)
|
||||
animation.onfinish = () => setIsAnimationComplete(true)
|
||||
|
||||
return () => {
|
||||
animation.cancel()
|
||||
}
|
||||
}, [isReady])
|
||||
|
||||
return (
|
||||
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 64 57"
|
||||
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
|
||||
<Path
|
||||
fill="#006AFF"
|
||||
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
<>
|
||||
{isReady && children}
|
||||
|
||||
{!isAnimationComplete && (
|
||||
<div
|
||||
ref={splashRef}
|
||||
style={flatten([
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
a.flex,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
// to compensate for the `top: -50px` below
|
||||
{transformOrigin: 'center calc(50% - 50px)'},
|
||||
])}>
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 64 57"
|
||||
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
|
||||
<Path
|
||||
fill="#006AFF"
|
||||
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
|
||||
/>
|
||||
</Svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -108,6 +108,22 @@ export const atoms = {
|
||||
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Visually hidden but available to screen readers (web).
|
||||
* Use for live regions or off-screen labels (e.g. "Image 1 of 3").
|
||||
*/
|
||||
sr_only: web({
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderWidth: 0,
|
||||
}),
|
||||
|
||||
/**
|
||||
* {@link Layout.SCROLLBAR_OFFSET}
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': {}
|
||||
@@ -875,4 +929,9 @@ export type Events = {
|
||||
'liveEvents:unhideAllFeedBanners': {
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
|
||||
'profile:associated:germ:click-to-chat': {}
|
||||
'profile:associated:germ:click-self-info': {}
|
||||
'profile:associated:germ:self-disconnect': {}
|
||||
'profile:associated:germ:self-reconnect': {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, {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 {useActorStatus} from '#/lib/actor-status'
|
||||
import {isJwtExpired} from '#/lib/jwt'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -12,6 +12,7 @@ import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {BotBadge} from '#/components/BotBadge'
|
||||
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'
|
||||
@@ -19,6 +20,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
|
||||
export function AccountList({
|
||||
onSelectAccount,
|
||||
@@ -171,6 +173,11 @@ function AccountItem({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{profile && (
|
||||
<View style={{marginTop: -2}}>
|
||||
<BotBadge profile={profile} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Robot_Filled_Corner2_Rounded as RobotIcon} from '#/components/icons/Robot'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {navigate} from '#/Navigation'
|
||||
|
||||
export function BotAccountAlert({
|
||||
control,
|
||||
isOwn,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
isOwn: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const description = isOwn
|
||||
? 'You have marked this account as automated. You can remove it at any time from your account settings.'
|
||||
: 'This account has been marked as automated by its owner'
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.ScrollableInner label={_(msg`Automated account`)}>
|
||||
<View style={[a.align_center, a.pb_md, a.shadow_sm]}>
|
||||
<RobotIcon width={48} fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
a.pb_xl,
|
||||
t.atoms.text_contrast_high,
|
||||
...(isOwn ? [a.text_md] : [a.text_lg]),
|
||||
...(!isOwn ? [a.font_semi_bold] : []),
|
||||
]}>
|
||||
<Trans>{description}</Trans>
|
||||
</Text>
|
||||
<View style={[a.w_full, a.gap_sm]}>
|
||||
<Button
|
||||
label={_(msg`Okay`)}
|
||||
onPress={() => control.close()}
|
||||
color="primary"
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Okay</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isOwn ? (
|
||||
<Button
|
||||
label={_(msg`Open settings`)}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
navigate('AutomationLabelSettings')
|
||||
})
|
||||
}}
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Open settings</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {View} from 'react-native'
|
||||
import {type ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isBotAccount} from '#/lib/bots'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {BotAccountAlert} from '#/components/BotAccountAlert'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Robot_Filled_Corner2_Rounded as RobotIcon} from '#/components/icons/Robot'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function BotBadge({
|
||||
profile,
|
||||
alwaysShow = false,
|
||||
size = 14,
|
||||
}: {
|
||||
profile: {did: string; labels?: ComAtprotoLabelDefs.Label[]}
|
||||
alwaysShow?: boolean
|
||||
size?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!isBotAccount(profile) && !alwaysShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<RobotIcon width={size} fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotBadgeButton({
|
||||
profile,
|
||||
size,
|
||||
isMe,
|
||||
}: {
|
||||
profile: {did: string; labels?: ComAtprotoLabelDefs.Label[]}
|
||||
size: 'lg' | 'md' | 'sm'
|
||||
isMe?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const control = useDialogControl()
|
||||
|
||||
if (!isBotAccount(profile)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let dimensions = 12
|
||||
if (size === 'lg') {
|
||||
dimensions = gtPhone ? 20 : 18
|
||||
} else if (size === 'md') {
|
||||
dimensions = 14
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`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: dimensions,
|
||||
height: dimensions,
|
||||
transform: [{scale: hovered ? 1.1 : 1}],
|
||||
},
|
||||
]}>
|
||||
<RobotIcon
|
||||
width={dimensions}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<BotAccountAlert control={control} isOwn={isMe} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import {KeyboardEvents} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
@@ -35,12 +36,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 +83,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,
|
||||
}
|
||||
|
||||
@@ -110,6 +112,7 @@ export function Root({children}: {children: React.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 +145,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
({
|
||||
isOpen: !!measurement && isFocused,
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
animationSV,
|
||||
translationSV,
|
||||
mode,
|
||||
@@ -149,6 +153,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 +162,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 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
}) satisfies ContextType,
|
||||
[
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
setMeasurement,
|
||||
onCompletedClose,
|
||||
isFocused,
|
||||
@@ -225,7 +235,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 +247,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 +257,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 +384,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 +409,7 @@ function TriggerClone({
|
||||
animation,
|
||||
image,
|
||||
measurement,
|
||||
returnLocation,
|
||||
onDisplay,
|
||||
label,
|
||||
}: {
|
||||
@@ -391,14 +417,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) => {
|
||||
@@ -874,6 +915,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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,11 +18,12 @@ 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: () => {},
|
||||
isWithinDialog: false,
|
||||
type: 'sheet',
|
||||
})
|
||||
Context.displayName = 'DialogContext'
|
||||
|
||||
|
||||
+166
-99
@@ -1,35 +1,30 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import React, {useImperativeHandle, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Modal,
|
||||
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'
|
||||
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 {
|
||||
@@ -37,8 +32,9 @@ import {
|
||||
type DialogInnerProps,
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {useDialogCallbackQueue} from '#/components/Dialog/utils'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
@@ -53,7 +49,93 @@ export * from '#/components/Dialog/utils'
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Outer({
|
||||
export function Outer(props: React.PropsWithChildren<DialogOuterProps>) {
|
||||
if (props.type === 'alert') {
|
||||
return <OuterAlert {...props} />
|
||||
}
|
||||
return <OuterSheet {...props} />
|
||||
}
|
||||
|
||||
function OuterAlert({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
testID,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const {enqueueCallback, handleOpen, handleClose} = useDialogCallbackQueue(
|
||||
control,
|
||||
onClose,
|
||||
)
|
||||
|
||||
const open: DialogControlProps['open'] = () => {
|
||||
handleOpen()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const close: DialogControlProps['close'] = cb => {
|
||||
enqueueCallback(cb)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const onDismiss = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
useImperativeHandle(control.ref, () => ({open, close}), [open, close])
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: true,
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: true,
|
||||
type: 'alert' as const,
|
||||
}),
|
||||
[close],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => close()}
|
||||
onDismiss={onDismiss}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => close()}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_xl,
|
||||
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||
]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={e => e.stopPropagation()}
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.w_full,
|
||||
{borderRadius: 48, maxWidth: 320, maxHeight: '90%'},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<Context.Provider value={context}>
|
||||
<View testID={testID} style={[a.flex_1]}>
|
||||
{children}
|
||||
</View>
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function OuterSheet({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
@@ -62,56 +144,33 @@ 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 {setDialogIsOpen, setFullyExpandedCount} =
|
||||
useDialogStateControlContext()
|
||||
const ref = useRef<BottomSheetNativeComponent>(null)
|
||||
const {enqueueCallback, handleOpen, handleClose, setFullyExpandedCount} =
|
||||
useDialogCallbackQueue(control, onClose)
|
||||
|
||||
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(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
} catch (e: any) {
|
||||
logger.error(e || 'Error running close callback')
|
||||
}
|
||||
}
|
||||
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(() => {
|
||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||
callQueuedCallbacks()
|
||||
setDialogIsOpen(control.id, true)
|
||||
const open: DialogControlProps['open'] = () => {
|
||||
handleOpen()
|
||||
ref.current?.present()
|
||||
}, [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 => {
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
}
|
||||
const close: DialogControlProps['close'] = cb => {
|
||||
enqueueCallback(cb)
|
||||
ref.current?.dismiss()
|
||||
}, [])
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
// 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)
|
||||
callQueuedCallbacks()
|
||||
onClose?.()
|
||||
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
|
||||
// Runs after the bottom sheet close animation completes.
|
||||
const onCloseAnimationComplete = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
|
||||
const {snapPoint} = e.nativeEvent
|
||||
@@ -142,23 +201,17 @@ export function Outer({
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
)
|
||||
useImperativeHandle(control.ref, () => ({open, close}), [open, close])
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: true,
|
||||
isNativeDialog: true,
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
isWithinDialog: true,
|
||||
type: 'sheet' as const,
|
||||
}),
|
||||
[close, snapPoint, disableDrag, setDisableDrag],
|
||||
)
|
||||
@@ -166,7 +219,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 +235,9 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({children, style, header}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
@@ -190,9 +247,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}
|
||||
@@ -208,33 +265,17 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
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 isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
|
||||
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)
|
||||
paddingBottom = tokens.space._2xl
|
||||
} else {
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom =
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
if (isAtMaxSnapPoint) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
paddingBottom +=
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -250,18 +291,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
{paddingBottom},
|
||||
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}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -271,7 +315,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
</ScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -283,10 +327,15 @@ export const InnerFlatList = React.forwardRef<
|
||||
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) {
|
||||
@@ -304,19 +353,36 @@ export const InnerFlatList = React.forwardRef<
|
||||
<ScrollProvider onScroll={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 {height} = useReanimatedKeyboardAnimation()
|
||||
@@ -330,6 +396,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
@@ -342,7 +409,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
a.pt_md,
|
||||
{
|
||||
paddingBottom: platform({
|
||||
ios: tokens.space.md + bottom,
|
||||
ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0),
|
||||
android: tokens.space.md + bottom + top,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -3,12 +3,13 @@ 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,6 +45,7 @@ export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
webOptions,
|
||||
type = 'sheet',
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -95,16 +97,19 @@ export function Outer({
|
||||
[close, open],
|
||||
)
|
||||
|
||||
const isAlert = type === 'alert'
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: false,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
isWithinDialog: true,
|
||||
type,
|
||||
}),
|
||||
[close],
|
||||
[close, type],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -123,7 +128,9 @@ export function Outer({
|
||||
a.inset_0,
|
||||
a.z_10,
|
||||
a.px_xl,
|
||||
webOptions?.alignCenter ? a.justify_center : undefined,
|
||||
webOptions?.alignCenter || isAlert
|
||||
? a.justify_center
|
||||
: undefined,
|
||||
a.align_center,
|
||||
{
|
||||
overflowY: 'auto',
|
||||
@@ -164,9 +171,10 @@ export function Inner({
|
||||
contentContainerStyle,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
const {close} = React.useContext(Context)
|
||||
const {close, type} = React.useContext(Context)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const isAlert = type === 'alert'
|
||||
FocusGuards.useFocusGuards()
|
||||
return (
|
||||
<FocusScope.FocusScope loop asChild trapped>
|
||||
@@ -188,12 +196,13 @@ export function Inner({
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
{
|
||||
maxWidth: 600,
|
||||
maxWidth: isAlert ? 320 : 600,
|
||||
borderColor: t.palette.contrast_200,
|
||||
shadowColor: t.palette.black,
|
||||
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
|
||||
shadowRadius: 30,
|
||||
},
|
||||
isAlert && {borderRadius: 36},
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
style,
|
||||
])}>
|
||||
@@ -253,11 +262,18 @@ export const InnerFlatList = React.forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -39,12 +39,13 @@ 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>>
|
||||
// in the event that the hook is used outside of a dialog
|
||||
isWithinDialog: boolean
|
||||
type: 'sheet' | 'alert'
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {
|
||||
@@ -65,6 +66,12 @@ export type DialogOuterProps = {
|
||||
alignCenter?: boolean
|
||||
onBackgroundPress?: (e: GestureResponderEvent) => void
|
||||
}
|
||||
/**
|
||||
* The presentation style of the dialog.
|
||||
* - `sheet` (default): Bottom sheet on native, standard modal on web.
|
||||
* - `alert`: Centered alert modal on all platforms.
|
||||
*/
|
||||
type?: 'sheet' | 'alert'
|
||||
testID?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
import React from 'react'
|
||||
import {useEffect, useRef} from 'react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {type DialogControlProps} from '#/components/Dialog/types'
|
||||
|
||||
export function useDialogCallbackQueue(
|
||||
control: DialogControlProps,
|
||||
onClose?: () => void,
|
||||
) {
|
||||
const closeCallbacks = useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen, setFullyExpandedCount} =
|
||||
useDialogStateControlContext()
|
||||
|
||||
const callQueuedCallbacks = () => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
} catch (e: any) {
|
||||
logger.error(e || 'Error running close callback')
|
||||
}
|
||||
}
|
||||
closeCallbacks.current = []
|
||||
}
|
||||
|
||||
const enqueueCallback = (cb?: () => void) => {
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
callQueuedCallbacks()
|
||||
setDialogIsOpen(control.id, true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
callQueuedCallbacks()
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueCallback,
|
||||
handleOpen,
|
||||
handleClose,
|
||||
setFullyExpandedCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAutoOpen(control: DialogControlProps, showTimeout?: number) {
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (showTimeout) {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
+23
-24
@@ -1,4 +1,4 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {useCallback, useEffect, useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -72,11 +71,11 @@ export function Link({
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
const href = useMemo(() => {
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
@@ -211,7 +210,7 @@ export function Description({
|
||||
description,
|
||||
...rest
|
||||
}: {description?: string} & Partial<RichTextProps>) {
|
||||
const rt = React.useMemo(() => {
|
||||
const rt = useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
@@ -278,7 +277,7 @@ function SaveButtonInner({
|
||||
pin?: boolean
|
||||
text?: boolean
|
||||
} & Partial<ButtonProps>) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
useAddSavedFeedsMutation()
|
||||
@@ -288,13 +287,13 @@ function SaveButtonInner({
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
const savedFeedConfig = useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
|
||||
const toggleSave = React.useCallback(
|
||||
const toggleSave = useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -311,17 +310,17 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
Toast.show(l`Failed to update feeds`, 'xmark')
|
||||
}
|
||||
},
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
)
|
||||
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
const onPromptRemoveFeed = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -334,11 +333,13 @@ function SaveButtonInner({
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
label={l`Add this feed to your feeds`}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||
onPress={(e: GestureResponderEvent) =>
|
||||
savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e)
|
||||
}
|
||||
{...buttonProps}>
|
||||
{savedFeedConfig ? (
|
||||
<>
|
||||
@@ -349,7 +350,7 @@ function SaveButtonInner({
|
||||
)}
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Unpin Feed</Trans>
|
||||
<Trans>Unpin feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -358,7 +359,7 @@ function SaveButtonInner({
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Pin Feed</Trans>
|
||||
<Trans>Pin feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -367,12 +368,10 @@ function SaveButtonInner({
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from your feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
title={l`Remove from your feeds?`}
|
||||
description={l`Are you sure you want to remove this from your feeds?`}
|
||||
onConfirm={(e: GestureResponderEvent) => void toggleSave(e)}
|
||||
confirmButtonCta={l`Remove`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -8,8 +8,9 @@ import Animated, {
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} 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 {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Fragment, useMemo} from 'react'
|
||||
import {Text as RNText} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
@@ -205,9 +206,8 @@ function KnownFollowersInner({
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans>
|
||||
</Trans> // only 2
|
||||
) : (
|
||||
// only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
import type React from 'react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {type AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
moderateUserList,
|
||||
type ModerationUI,
|
||||
} 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 {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {memo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} 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 {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} 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 flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -272,7 +273,8 @@ export function ContainerItem({
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -310,7 +312,8 @@ export function Group({children, style}: GroupProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DropdownMenu} from 'radix-ui'
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs, moderateProfile} 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 {differenceInSeconds} from 'date-fns'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
@@ -29,7 +30,7 @@ export function NewskieDialog({
|
||||
const {_} = useLingui()
|
||||
const control = useDialogControl()
|
||||
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
const createdAt = profile.createdAt
|
||||
|
||||
const [now] = useState(() => Date.now())
|
||||
const daysOld = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {BSKY_LABELER_DID, type ModerationCause} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useCallback} from 'react'
|
||||
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 {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user