Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bed38ac76 | |||
| b7ebad18c1 | |||
| c71d91dfce |
@@ -46,6 +46,3 @@ GEOLOCATION_DEV_URL=
|
||||
|
||||
# live-events web worker URL
|
||||
LIVE_EVENTS_DEV_URL=
|
||||
|
||||
# app-config web worker URL
|
||||
APP_CONFIG_DEV_URL=
|
||||
|
||||
+1
-3
@@ -110,9 +110,7 @@ google-services.json
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/messages.js
|
||||
src/locale/locales/**/messages.mjs
|
||||
src/locale/locales/**/messages.ts
|
||||
src/locale/locales/**/*.js
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
|
||||
@@ -24,15 +24,13 @@ 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
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
yarn intl:extract # Extract translation strings (nightly CI job)
|
||||
yarn intl:compile # Compile translations for runtime (nightly CI job)
|
||||
yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it)
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
|
||||
# Build
|
||||
yarn build-web # Build web version
|
||||
@@ -46,7 +44,6 @@ 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
|
||||
@@ -61,121 +58,6 @@ 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.
|
||||
@@ -387,8 +269,7 @@ import * as TextField from '#/components/forms/TextField'
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {msg, Trans, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
@@ -418,9 +299,8 @@ function MyComponent() {
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
yarn intl:extract # Extract new strings to locale files
|
||||
yarn intl:compile # Compile translations for runtime
|
||||
yarn intl:compile # Compile for runtime (required after changes)
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
@@ -39,6 +39,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "editListNameInput"
|
||||
- eraseText
|
||||
- inputText: "Bad Ppl"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "editListDescriptionInput"
|
||||
- eraseText
|
||||
|
||||
@@ -35,12 +35,9 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-timeline-moveDown"
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
@@ -58,12 +55,9 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-feed-moveDown"
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
|
||||
@@ -15,11 +15,6 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
|
||||
@@ -26,7 +26,6 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
+9
-23
@@ -3,29 +3,15 @@ appId: xyz.blueskyweb.app
|
||||
- launchApp:
|
||||
appId: "xyz.blueskyweb.app"
|
||||
clearState: true
|
||||
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
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "http://localhost:8081"
|
||||
- waitForAnimationToEnd
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
- swipe:
|
||||
from: "Bluesky"
|
||||
direction: DOWN
|
||||
duration: 100
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
requestPermission: jest.fn(),
|
||||
onForegroundEvent: jest.fn(),
|
||||
setBadgeCount: jest.fn(),
|
||||
displayNotification: jest.fn(),
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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'}}},
|
||||
],
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
configure: jest.fn().mockResolvedValue(0),
|
||||
finish: jest.fn(),
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default {}
|
||||
@@ -0,0 +1,10 @@
|
||||
jest.mock('rn-fetch-blob', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
fs: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DropdownMenu = jest.fn().mockImplementation(() => {})
|
||||
export const create = jest.fn().mockImplementation(() => {})
|
||||
@@ -1,5 +1,4 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
import {
|
||||
@@ -7,7 +6,6 @@ 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'
|
||||
@@ -204,9 +202,6 @@ describe('enforceLen', () => {
|
||||
})
|
||||
|
||||
describe('cleanError', () => {
|
||||
// cleanError uses lingui
|
||||
i18n.loadAndActivate({locale: 'en', messages})
|
||||
|
||||
const inputs = [
|
||||
'TypeError: Network request failed',
|
||||
'Error: Aborted',
|
||||
@@ -332,7 +327,6 @@ 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])
|
||||
}
|
||||
}
|
||||
@@ -443,13 +437,6 @@ 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 = [
|
||||
@@ -828,23 +815,6 @@ 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', () => {
|
||||
|
||||
+9
-20
@@ -35,13 +35,6 @@ 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,
|
||||
@@ -62,7 +55,10 @@ module.exports = function (_config) {
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
icon: IOS_ICON_FILE,
|
||||
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',
|
||||
infoPlist: {
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
@@ -116,13 +112,13 @@ module.exports = function (_config) {
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
],
|
||||
UIDesignRequiresCompatibility: true,
|
||||
},
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
entitlements: {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
@@ -259,13 +255,6 @@ 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,
|
||||
@@ -323,22 +312,22 @@ module.exports = function (_config) {
|
||||
{
|
||||
ios: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
backgroundColor: '#A8CCFF', // primary_200
|
||||
image: './assets/splash/splash.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#002861', // primary_900
|
||||
backgroundColor: '#00398A', // primary_800
|
||||
image: './assets/splash/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
backgroundColor: '#A8CCFF', // primary_200
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102, // even division of 306px
|
||||
dark: {
|
||||
backgroundColor: '#002861', // primary_900
|
||||
backgroundColor: '#00398A', // primary_800
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102,
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 771 KiB |
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"value" : "overlay"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "screen"
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : "screen"
|
||||
}
|
||||
],
|
||||
"blur-material-specializations" : [
|
||||
{
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : null
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "TestFlight notice.png",
|
||||
"name" : "TestFlight notice"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"position" : {
|
||||
"scale" : 0.4,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
350
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"translucency-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : "none",
|
||||
"glass" : false,
|
||||
"image-name" : "iOS transparent.png",
|
||||
"name" : "iOS transparent"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 357 B |
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB |
+1
-1
@@ -16,7 +16,7 @@ module.exports = function (api) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
'macros',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
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:${detectMime(src)};base64,${src.toString('base64')}`}
|
||||
/>
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
)
|
||||
}
|
||||
|
||||
+14
-21
@@ -6,26 +6,21 @@ 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:
|
||||
|
||||
```bash
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
# 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:
|
||||
|
||||
|
||||
```bash
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
```
|
||||
# in the top level of this repo
|
||||
yarn build-web
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
@@ -33,13 +28,11 @@ Install golang. We generally develop against the current stable release of the l
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
```bash
|
||||
# re-build and run daemon
|
||||
go run ./cmd/bskyweb serve
|
||||
# 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,14 +2,12 @@ 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) {
|
||||
@@ -28,8 +26,3 @@ 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,10 +574,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.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
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
@@ -585,20 +582,12 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasVideo {
|
||||
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
} else if hasMediaImages {
|
||||
} else if hasMedia {
|
||||
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,13 +178,6 @@ 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))
|
||||
@@ -212,7 +205,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, oembedCORS)
|
||||
e.GET("/oembed", server.WebOEmbed)
|
||||
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|avatar_thumbnail }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||
<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
|
||||
|
||||
|
||||
+5
-20
@@ -73,7 +73,7 @@ import { Text } from "react-native";
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Trans } from "@lingui/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
@@ -90,33 +90,18 @@ const text = "Hello World";
|
||||
```
|
||||
In this case, you can use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { msg } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
|
||||
|
||||
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 { useLingui } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/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.
|
||||
@@ -136,7 +121,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/core/macro";
|
||||
import { msg } from "@lingui/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
@@ -9,14 +9,6 @@ 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,6 +23,8 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party/**',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
|
||||
+2
-2
@@ -1,5 +1,7 @@
|
||||
/* 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'
|
||||
|
||||
@@ -34,7 +36,6 @@ 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(),
|
||||
}))
|
||||
|
||||
@@ -44,7 +45,6 @@ jest.mock('expo-image-manipulator', () => ({
|
||||
}),
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {defineConfig} from '@lingui/cli'
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
locales: [
|
||||
'en',
|
||||
'an',
|
||||
@@ -51,5 +49,5 @@ export default defineConfig({
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
compileNamespace: 'ts',
|
||||
})
|
||||
format: 'po',
|
||||
}
|
||||
@@ -44,6 +44,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation "com.facebook.react:react-native:+"
|
||||
}
|
||||
|
||||
-2
@@ -48,8 +48,6 @@ class BottomSheetModule : Module() {
|
||||
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-115
@@ -5,12 +5,8 @@ import android.util.DisplayMetrics
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewStructure
|
||||
import android.view.Window
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.allViews
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
@@ -19,7 +15,6 @@ import com.facebook.react.uimanager.UIManagerHelper
|
||||
import com.facebook.react.uimanager.events.EventDispatcher
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.internal.EdgeToEdgeUtils
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
@@ -34,26 +29,22 @@ class BottomSheetView(
|
||||
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
private var isKeyboardVisible: Boolean = false
|
||||
|
||||
private val screenHeight =
|
||||
private val rawScreenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private fun getStatusBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
|
||||
private val onAttemptDismiss by EventDispatcher()
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
private val onStateChange by EventDispatcher()
|
||||
|
||||
// Props
|
||||
var disableDrag = false
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -65,31 +56,48 @@ class BottomSheetView(
|
||||
field = value
|
||||
this.dialog?.setCancelable(!value)
|
||||
}
|
||||
|
||||
var preventExpansion = false
|
||||
|
||||
var minHeight = 0f
|
||||
set(value) {
|
||||
field = if (value < 0) 0f else dpToPx(value)
|
||||
field =
|
||||
if (value < 0) {
|
||||
0f
|
||||
} else {
|
||||
dpToPx(value)
|
||||
}
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
var maxHeight = this.safeScreenHeight
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field = if (px > this.screenHeight) this.screenHeight else px
|
||||
field =
|
||||
if (px > this.safeScreenHeight) {
|
||||
this.safeScreenHeight
|
||||
} else {
|
||||
px
|
||||
}
|
||||
}
|
||||
|
||||
private var isOpen: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
onStateChange(mapOf("state" to if (value) "open" else "closed"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to if (value) "open" else "closed",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private var isOpening: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
onStateChange(mapOf("state" to "opening"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to "opening",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,21 +105,33 @@ class BottomSheetView(
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
onStateChange(mapOf("state" to "closing"))
|
||||
onStateChange(
|
||||
mapOf(
|
||||
"state" to "closing",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedSnapPoint = 0
|
||||
set(value) {
|
||||
if (field == value) return
|
||||
|
||||
field = value
|
||||
onSnapPointChange(mapOf("snapPoint" to value))
|
||||
onSnapPointChange(
|
||||
mapOf(
|
||||
"snapPoint" to value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
init {
|
||||
(appContext.reactContext as? ReactContext)?.let {
|
||||
it.addLifecycleEventListener(this)
|
||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||
|
||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
||||
}
|
||||
@@ -141,55 +161,27 @@ class BottomSheetView(
|
||||
private fun getHalfExpandedRatio(contentHeight: Float): Float =
|
||||
when {
|
||||
// Full height sheets
|
||||
contentHeight >= screenHeight -> 0.99f
|
||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||
contentHeight >= safeScreenHeight -> 0.99f
|
||||
// Medium height sheets (>50% but <100%)
|
||||
contentHeight >= safeScreenHeight / 2 ->
|
||||
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
|
||||
// Small height sheets (<50%)
|
||||
else ->
|
||||
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
|
||||
}
|
||||
|
||||
private fun present() {
|
||||
if (this.isOpen || this.isOpening || this.isClosing) return
|
||||
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
var activityWindow: Window? = null
|
||||
var currentContext = context
|
||||
while (currentContext != null) {
|
||||
if (currentContext is android.app.Activity) {
|
||||
activityWindow = currentContext.window
|
||||
break
|
||||
}
|
||||
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
|
||||
}
|
||||
|
||||
val originalStatusBarAppearance =
|
||||
activityWindow?.let { window ->
|
||||
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
|
||||
}
|
||||
val originalNavBarAppearance =
|
||||
activityWindow?.let { window ->
|
||||
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
|
||||
}
|
||||
|
||||
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
|
||||
val dialog = BottomSheetDialog(context)
|
||||
dialog.setContentView(dialogRootViewGroup)
|
||||
dialog.setCancelable(!preventDismiss)
|
||||
dialog.setDismissWithAnimation(true)
|
||||
dialog.setOnDismissListener {
|
||||
this.isClosing = true
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
dialog.setOnShowListener {
|
||||
dialog.window?.let { window ->
|
||||
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
if (originalNavBarAppearance != null) {
|
||||
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
|
||||
}
|
||||
if (originalStatusBarAppearance != null) {
|
||||
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
it.setBackgroundColor(0)
|
||||
@@ -202,17 +194,7 @@ class BottomSheetView(
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
} else {
|
||||
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
@@ -227,10 +209,18 @@ class BottomSheetView(
|
||||
newState: Int,
|
||||
) {
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
||||
BottomSheetBehavior.STATE_EXPANDED -> {
|
||||
selectedSnapPoint = 2
|
||||
}
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> {
|
||||
selectedSnapPoint = 1
|
||||
}
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
|
||||
selectedSnapPoint = 1
|
||||
}
|
||||
BottomSheetBehavior.STATE_HIDDEN -> {
|
||||
selectedSnapPoint = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,26 +231,9 @@ class BottomSheetView(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
this.isOpening = true
|
||||
dialog.show()
|
||||
this.dialog = dialog
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
|
||||
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
|
||||
|
||||
val wasKeyboardVisible = isKeyboardVisible
|
||||
isKeyboardVisible = imeVisible
|
||||
|
||||
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!imeVisible && wasKeyboardVisible) {
|
||||
updateLayout()
|
||||
}
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
@@ -273,24 +246,12 @@ class BottomSheetView(
|
||||
val currentState = behavior.state
|
||||
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
val newRatio = getHalfExpandedRatio(contentHeight)
|
||||
var newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
@@ -318,19 +279,25 @@ class BottomSheetView(
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
val contentHeight = this.getContentHeight()
|
||||
return when {
|
||||
contentHeight > maxHeight -> maxHeight
|
||||
contentHeight < minHeight -> minHeight
|
||||
else -> contentHeight
|
||||
}
|
||||
val height =
|
||||
if (contentHeight > maxHeight) {
|
||||
maxHeight
|
||||
} else if (contentHeight < minHeight) {
|
||||
minHeight
|
||||
} else {
|
||||
contentHeight
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
private fun clampRatio(ratio: Float): Float =
|
||||
when {
|
||||
ratio < 0.01 -> 0.01f
|
||||
ratio > 0.99 -> 0.99f
|
||||
else -> ratio
|
||||
private fun clampRatio(ratio: Float): Float {
|
||||
if (ratio < 0.01) {
|
||||
return 0.01f
|
||||
} else if (ratio > 0.99) {
|
||||
return 0.99f
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
private fun setDraggable(draggable: Boolean) {
|
||||
val dialog = this.dialog ?: return
|
||||
@@ -355,7 +322,9 @@ class BottomSheetView(
|
||||
// View overrides to pass to DialogRootViewGroup instead
|
||||
|
||||
override fun dispatchProvideStructure(structure: ViewStructure?) {
|
||||
if (structure == null) return
|
||||
if (structure == null) {
|
||||
return
|
||||
}
|
||||
dialogRootViewGroup.dispatchProvideStructure(structure)
|
||||
}
|
||||
|
||||
@@ -394,6 +363,7 @@ class BottomSheetView(
|
||||
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
|
||||
fun dpToPx(dp: Float): Float {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
|
||||
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
|
||||
return px
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -52,8 +52,6 @@ class DialogRootViewGroup(
|
||||
if (ReactFeatureFlags.dispatchPointerEvents) {
|
||||
jSPointerDispatcher = JSPointerDispatcher(this)
|
||||
}
|
||||
|
||||
fitsSystemWindows = false
|
||||
}
|
||||
|
||||
override fun onSizeChanged(
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="enableEdgeToEdge">true</item>
|
||||
|
||||
<!-- Configure bottom sheet to respect system window insets -->
|
||||
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
|
||||
</style>
|
||||
|
||||
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
|
||||
<item name="paddingBottomSystemWindowInsets">false</item>
|
||||
<item name="paddingLeftSystemWindowInsets">true</item>
|
||||
<item name="paddingRightSystemWindowInsets">true</item>
|
||||
<item name="paddingTopSystemWindowInsets">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -42,10 +42,6 @@ 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,7 +26,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
var preventDismiss = false
|
||||
var preventExpansion = false
|
||||
var cornerRadius: CGFloat?
|
||||
var sourceViewTag: Int?
|
||||
var minHeight = 0.0
|
||||
var maxHeight: CGFloat! {
|
||||
didSet {
|
||||
@@ -136,15 +135,6 @@ 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,19 +27,6 @@ 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 = [
|
||||
@@ -49,7 +36,7 @@ class SheetViewController: UIViewController {
|
||||
} else {
|
||||
sheet.detents = [
|
||||
.custom { _ in
|
||||
return adjustedHeight
|
||||
return contentHeight
|
||||
}
|
||||
]
|
||||
if !preventExpansion {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
|
||||
import React from 'react'
|
||||
import {ColorValue, NativeSyntheticEvent} from 'react-native'
|
||||
|
||||
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
|
||||
|
||||
@@ -24,7 +25,6 @@ export interface BottomSheetViewProps {
|
||||
backgroundColor?: ColorValue
|
||||
containerBackgroundColor?: ColorValue
|
||||
disableDrag?: boolean
|
||||
sourceViewTag?: number
|
||||
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
Platform,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
} from './BottomSheet.types'
|
||||
import {
|
||||
BottomSheetPortalProvider,
|
||||
Context as PortalContext,
|
||||
} from './BottomSheetPortal'
|
||||
import {BottomSheetPortalProvider} from './BottomSheetPortal'
|
||||
import {Context as PortalContext} from './BottomSheetPortal'
|
||||
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
@@ -93,7 +92,6 @@ 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) {
|
||||
@@ -154,7 +152,6 @@ function BottomSheetNativeComponentInner({
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
@@ -178,7 +175,6 @@ function BottomSheetNativeComponentInner({
|
||||
Platform.OS === 'android' && {
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
extraStyles,
|
||||
]}>
|
||||
|
||||
-3
@@ -34,15 +34,12 @@ class NotificationPrefs(
|
||||
is Boolean -> {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
|
||||
is String -> {
|
||||
putString(key, value)
|
||||
}
|
||||
|
||||
is Array<*> -> {
|
||||
putStringSet(key, value.map { it.toString() }.toSet())
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
putStringSet(key, value.map { it.toString() }.toSet())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MCEmojiPicker'
|
||||
s.dependency 'MCEmojiPicker', '1.2.3'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
|
||||
+2
-2
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun handleImageIntents(
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
text: String?
|
||||
) {
|
||||
var allParams = ""
|
||||
|
||||
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
|
||||
private fun handleVideoIntents(
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
text: String?
|
||||
) {
|
||||
val uri = uris[0]
|
||||
// If there is no extension for the file, substringAfterLast returns the original string - not
|
||||
|
||||
+21
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.118.0",
|
||||
"version": "1.116.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -16,13 +16,6 @@
|
||||
"expo-image-picker"
|
||||
]
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native-reanimated",
|
||||
"@sentry/react-native",
|
||||
"react-native-pager-view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -66,7 +59,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.ts ] || yarn intl:compile",
|
||||
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || 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",
|
||||
@@ -80,15 +73,13 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.3",
|
||||
"@atproto/api": "^0.18.18",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.7",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
"@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",
|
||||
@@ -102,12 +93,10 @@
|
||||
"@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": "^1.6.5",
|
||||
"@growthbook/growthbook-react": "^1.6.5",
|
||||
"@growthbook/growthbook-react": "^1.6.2",
|
||||
"@haileyok/bluesky-video": "0.3.2",
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@lingui/react": "^4.14.1",
|
||||
"@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",
|
||||
@@ -134,13 +123,15 @@
|
||||
"@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.6.0",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54.0.27",
|
||||
@@ -172,12 +163,14 @@
|
||||
"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",
|
||||
@@ -208,7 +201,8 @@
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-keyboard-controller": "1.18.5",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
@@ -217,6 +211,7 @@
|
||||
"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",
|
||||
@@ -225,7 +220,6 @@
|
||||
"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",
|
||||
@@ -235,19 +229,20 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.209",
|
||||
"@atproto/dev-env": "^0.3.206",
|
||||
"@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/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
"@react-native/typescript-config": "^0.81.5",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.2.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
@@ -257,6 +252,7 @@
|
||||
"@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",
|
||||
@@ -286,8 +282,8 @@
|
||||
"svgo": "^3.3.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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..3602856 100644
|
||||
index c34ba71..13d576a 100644
|
||||
--- a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
|
||||
+++ b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
|
||||
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
@@ -159,13 +159,23 @@ 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,8 +27,6 @@ index c34ba71..3602856 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))
|
||||
}
|
||||
|
||||
+37
-47
@@ -3,7 +3,6 @@ import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
@@ -11,22 +10,18 @@ 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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
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'
|
||||
@@ -108,7 +103,6 @@ if (IS_ANDROID) {
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -180,11 +174,9 @@ function InnerApp() {
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TranslateOnDeviceProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
@@ -236,40 +228,38 @@ function App() {
|
||||
*/
|
||||
return (
|
||||
<Geo.Provider>
|
||||
<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>
|
||||
<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>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+88
-97
@@ -2,22 +2,17 @@ import '#/logger/sentry/setup' // must be near top
|
||||
import '#/view/icons'
|
||||
import './style.css'
|
||||
|
||||
import {Fragment, useEffect, useState} from 'react'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg} from '@lingui/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'
|
||||
@@ -84,10 +79,9 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
@@ -122,69 +116,68 @@ function InnerApp() {
|
||||
})
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<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>
|
||||
<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>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
@@ -194,14 +187,14 @@ function InnerApp() {
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
return <Splash isReady />
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -210,33 +203,31 @@ function App() {
|
||||
*/
|
||||
return (
|
||||
<Geo.Provider>
|
||||
<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>
|
||||
<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>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
+27
-47
@@ -1,8 +1,8 @@
|
||||
import {type JSX, useCallback, useRef} from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {Linking} from 'react-native'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {i18n, type MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
type BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
@@ -138,7 +138,7 @@ import {
|
||||
} from '#/components/dialogs/EmailDialog'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -685,30 +685,10 @@ 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}
|
||||
options={BLURRED_SCROLL_EDGE_EFFECT}
|
||||
/>
|
||||
<HomeTab.Screen
|
||||
name="Start"
|
||||
getComponent={() => HomeScreen}
|
||||
options={BLURRED_SCROLL_EDGE_EFFECT}
|
||||
/>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab as typeof Flat)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -894,6 +874,11 @@ 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)
|
||||
@@ -904,7 +889,6 @@ 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.
|
||||
@@ -939,30 +923,29 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
)
|
||||
|
||||
function handlePushNotificationEntry() {
|
||||
async function handlePushNotificationEntry() {
|
||||
if (!IS_NATIVE) return
|
||||
|
||||
// intent urls are handled by `useIntentHandler`
|
||||
if (linkingUrl) 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
|
||||
}
|
||||
|
||||
const notificationResponse = Notifications.getLastNotificationResponse()
|
||||
/**
|
||||
* The notification that caused the app to open, if applicable
|
||||
*/
|
||||
const response = await Notifications.getLastNotificationResponseAsync()
|
||||
|
||||
if (notificationResponse) {
|
||||
notyLogger.debug(`handlePushNotificationEntry: response`, {
|
||||
response: notificationResponse,
|
||||
})
|
||||
if (response) {
|
||||
notyLogger.debug(`handlePushNotificationEntry: response`, {response})
|
||||
|
||||
// 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},
|
||||
)
|
||||
}
|
||||
if (response.notification.date === lastHandledNotificationDateDedupe)
|
||||
return
|
||||
lastHandledNotificationDateDedupe = response.notification.date
|
||||
|
||||
const payload = getNotificationPayload(notificationResponse.notification)
|
||||
const payload = getNotificationPayload(response.notification)
|
||||
|
||||
if (payload) {
|
||||
ax.metric('notifications:openApp', {
|
||||
@@ -1028,9 +1011,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// temp, just testing
|
||||
void ax.features.enabled(ax.features.AATest)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
+1
-2
@@ -146,8 +146,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 400, easing: Easing.out(Easing.cubic)},
|
||||
() => {
|
||||
'worklet'
|
||||
async () => {
|
||||
// set these values to check animation at specific point
|
||||
outroLogo.set(() =>
|
||||
withTiming(
|
||||
|
||||
+14
-82
@@ -4,94 +4,26 @@
|
||||
* the app is ready to go.
|
||||
*/
|
||||
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
const size = 100
|
||||
const ratio = 57 / 64
|
||||
|
||||
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])
|
||||
|
||||
export function Splash() {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
SupportCode,
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from 'react'
|
||||
import {Dimensions, View} from 'react-native'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
@@ -13,7 +14,6 @@ import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
hasSnoozedBirthdateUpdateForDid,
|
||||
@@ -45,7 +45,7 @@ const qc = new QueryClient({
|
||||
},
|
||||
})
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: createPersistedQueryStorage('age-assurance'),
|
||||
storage: AsyncStorage,
|
||||
key: 'age-assurance-query-client',
|
||||
})
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
|
||||
@@ -108,22 +108,6 @@ 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,6 +9,4 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -18,16 +18,6 @@ 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,16 +21,6 @@ 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)
|
||||
|
||||
|
||||
+15
-20
@@ -1,4 +1,4 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {createContext, useContext, useEffect, 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 {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
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(['geolocationServiceResponse']) || {
|
||||
geolocation: device.get(['mergedGeolocation']) || {
|
||||
countryCode: '',
|
||||
regionCode: '',
|
||||
},
|
||||
@@ -137,7 +137,7 @@ export function AnalyticsContext({
|
||||
}
|
||||
}
|
||||
const sessionId = useSessionId()
|
||||
const geolocation = useGeolocationServiceResponse()
|
||||
const geolocation = useGeolocation()
|
||||
const parentContext = useContext(Context)
|
||||
const childContext = useMemo(() => {
|
||||
const combinedMetadata = {
|
||||
@@ -181,25 +181,20 @@ export function AnalyticsFeaturesContext({
|
||||
const parentContext = useContext(Context)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
setAttributes(parentContext.metadata)
|
||||
feats.setTrackingCallback((experiment, result) => {
|
||||
parentContext.metric('experiment:viewed', {
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
|
||||
useEffect(() => {
|
||||
feats.setTrackingCallback((experiment, result) => {
|
||||
parentContext.metric('experiment:viewed', {
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
})
|
||||
})
|
||||
})
|
||||
feats.setFeatureUsageCallback((feature, result) => {
|
||||
parentContext.metric('feature:viewed', {
|
||||
featureId: feature,
|
||||
featureResultValue: result.value,
|
||||
experimentId: result.experiment?.key,
|
||||
variationId: result.experimentResult?.key,
|
||||
})
|
||||
})
|
||||
}, [parentContext.metric])
|
||||
|
||||
const childContext = useMemo<AnalyticsContextType>(() => {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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]
|
||||
@@ -44,8 +43,7 @@ export class MetricsClient<M extends Record<string, any>> {
|
||||
) {
|
||||
this.start()
|
||||
|
||||
const e: Event<M> = {
|
||||
source: 'app',
|
||||
const e = {
|
||||
time: Date.now(),
|
||||
event,
|
||||
payload,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* 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'
|
||||
@@ -17,14 +15,6 @@ 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:
|
||||
@@ -477,8 +467,8 @@ export type Events = {
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recId?: number | string
|
||||
location: 'Card' | 'Profile'
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -489,7 +479,7 @@ export type Events = {
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -502,7 +492,7 @@ export type Events = {
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
| 'ProgressGuide'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -517,7 +507,7 @@ export type Events = {
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
}
|
||||
@@ -573,10 +563,6 @@ export type Events = {
|
||||
profilesCount: number
|
||||
feedsCount: number
|
||||
}
|
||||
'starterPack:convertToList': {
|
||||
starterPack: string
|
||||
memberCount: number
|
||||
}
|
||||
'starterPack:ctaPress': {
|
||||
starterPack: string
|
||||
}
|
||||
@@ -649,32 +635,6 @@ 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': {}
|
||||
|
||||
@@ -707,17 +667,6 @@ 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': {}
|
||||
@@ -926,9 +875,4 @@ 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} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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'
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
|
||||
@@ -9,6 +9,10 @@ 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'
|
||||
}
|
||||
@@ -31,7 +35,7 @@ export function Icon() {
|
||||
const fill = {
|
||||
info: t.atoms.text_contrast_medium.color,
|
||||
tip: t.palette.primary_500,
|
||||
warning: t.palette.yellow,
|
||||
warning: colors.warning,
|
||||
error: t.palette.negative_500,
|
||||
apology: t.atoms.text_contrast_medium.color,
|
||||
}[type]
|
||||
@@ -106,7 +110,7 @@ export function Outer({
|
||||
const borderColor = {
|
||||
info: t.atoms.border_contrast_high.borderColor,
|
||||
tip: t.palette.primary_500,
|
||||
warning: t.palette.yellow,
|
||||
warning: colors.warning,
|
||||
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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
type ContextType,
|
||||
@@ -6,17 +6,17 @@ import {
|
||||
type MenuContextType,
|
||||
} from '#/components/ContextMenu/types'
|
||||
|
||||
export const Context = createContext<ContextType | null>(null)
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
Context.displayName = 'ContextMenuContext'
|
||||
|
||||
export const MenuContext = createContext<MenuContextType | null>(null)
|
||||
export const MenuContext = React.createContext<MenuContextType | null>(null)
|
||||
MenuContext.displayName = 'ContextMenuMenuContext'
|
||||
|
||||
export const ItemContext = createContext<ItemContextType | null>(null)
|
||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||
ItemContext.displayName = 'ContextMenuItemContext'
|
||||
|
||||
export function useContextMenuContext() {
|
||||
const context = useContext(Context)
|
||||
const context = React.useContext(Context)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -28,7 +28,7 @@ export function useContextMenuContext() {
|
||||
}
|
||||
|
||||
export function useContextMenuMenuContext() {
|
||||
const context = useContext(MenuContext)
|
||||
const context = React.useContext(MenuContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -40,7 +40,7 @@ export function useContextMenuMenuContext() {
|
||||
}
|
||||
|
||||
export function useContextMenuItemContext() {
|
||||
const context = useContext(ItemContext)
|
||||
const context = React.useContext(ItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import {KeyboardEvents} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
@@ -36,13 +35,12 @@ 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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
@@ -83,9 +81,9 @@ export {
|
||||
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||
|
||||
const SPRING_IN: WithSpringConfig = {
|
||||
mass: 0.75,
|
||||
damping: 300,
|
||||
stiffness: 1200,
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 50,
|
||||
stiffness: 1100,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
@@ -112,7 +110,6 @@ 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()
|
||||
@@ -145,7 +142,6 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
({
|
||||
isOpen: !!measurement && isFocused,
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
animationSV,
|
||||
translationSV,
|
||||
mode,
|
||||
@@ -153,8 +149,6 @@ 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(
|
||||
@@ -162,9 +156,6 @@ 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)()
|
||||
}
|
||||
}),
|
||||
@@ -203,7 +194,6 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
}) satisfies ContextType,
|
||||
[
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
setMeasurement,
|
||||
onCompletedClose,
|
||||
isFocused,
|
||||
@@ -235,7 +225,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const insets = useSafeAreaInsets()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const ref = useRef<View>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
@@ -247,8 +237,23 @@ 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([
|
||||
measureView(ref.current, insets),
|
||||
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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
captureRef(ref, {result: 'data-uri'}).catch(err => {
|
||||
logger.error(err instanceof Error ? err : String(err), {
|
||||
message: 'Failed to capture image of context menu trigger',
|
||||
@@ -257,45 +262,16 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
return '<failed capture>'
|
||||
}),
|
||||
])
|
||||
Keyboard.dismiss()
|
||||
setImage(capture)
|
||||
if (measurement) {
|
||||
setPendingMeasurement({measurement, mode})
|
||||
}
|
||||
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(() => void open('auxiliary-only'))
|
||||
.onEnd(() => open('auxiliary-only'))
|
||||
.runOnJS(true)
|
||||
}, [open])
|
||||
|
||||
@@ -384,7 +360,6 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
animation={animationSV}
|
||||
image={image}
|
||||
measurement={measurement}
|
||||
returnLocation={context.returnLocationSV}
|
||||
onDisplay={() => {
|
||||
if (pendingMeasurement) {
|
||||
context.open(
|
||||
@@ -409,7 +384,6 @@ function TriggerClone({
|
||||
animation,
|
||||
image,
|
||||
measurement,
|
||||
returnLocation,
|
||||
onDisplay,
|
||||
label,
|
||||
}: {
|
||||
@@ -417,29 +391,14 @@ 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(() => {
|
||||
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 animatedStyles = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translation.get() * animation.get()}],
|
||||
}))
|
||||
|
||||
const handleError = useCallback(
|
||||
(evt: ImageErrorEventData) => {
|
||||
@@ -915,25 +874,6 @@ 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,7 +49,6 @@ 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,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
|
||||
|
||||
export const Context = createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
isNativeDialog: false,
|
||||
IS_NATIVEDialog: false,
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
type ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
useKeyboardHandler,
|
||||
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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
|
||||
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 {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -34,7 +38,7 @@ import {
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
@@ -150,7 +154,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: true,
|
||||
IS_NATIVEDialog: true,
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
@@ -162,8 +166,7 @@ export function Outer({
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={ref}
|
||||
// device-bezel radius when undefined
|
||||
cornerRadius={IS_LIQUID_GLASS ? undefined : 20}
|
||||
cornerRadius={20}
|
||||
backgroundColor={t.atoms.bg.backgroundColor}
|
||||
{...nativeOptions}
|
||||
onSnapPointChange={onSnapPointChange}
|
||||
@@ -178,9 +181,6 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({children, style, header}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
@@ -190,9 +190,9 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
IS_LIQUID_GLASS
|
||||
? a.pb_2xl
|
||||
: {paddingBottom: insets.bottom + insets.top},
|
||||
{
|
||||
paddingBottom: insets.bottom + insets.top,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
@@ -208,17 +208,35 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
|
||||
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
|
||||
|
||||
useKeyboardHandler(
|
||||
{
|
||||
onEnd: e => {
|
||||
'worklet'
|
||||
runOnJS(setKeyboardHeight)(e.height)
|
||||
},
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
let paddingBottom = 0
|
||||
if (IS_IOS) {
|
||||
paddingBottom = tokens.space._2xl
|
||||
paddingBottom += keyboardHeight / 4
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.bottom + tokens.space.md
|
||||
}
|
||||
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
|
||||
} else {
|
||||
paddingBottom =
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
if (isAtMaxSnapPoint) {
|
||||
paddingBottom += keyboardHeight
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
paddingBottom +=
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -234,21 +252,18 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
a.px_xl,
|
||||
{paddingBottom},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
bottomOffset={30}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={IS_ANDROID ? onScroll : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -258,7 +273,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -270,14 +285,11 @@ export const InnerFlatList = React.forwardRef<
|
||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
>(function InnerFlatList(
|
||||
{headerOffset, footer, style, contentContainerStyle, ...props},
|
||||
ref,
|
||||
) {
|
||||
>(function InnerFlatList({footer, style, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
|
||||
const onScroll = (e: ScrollEvent) => {
|
||||
'worklet'
|
||||
@@ -296,36 +308,19 @@ export const InnerFlatList = React.forwardRef<
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
scrollIndicatorInsets={{top: headerOffset}}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||
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,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
const {top, bottom} = useSafeAreaInsets()
|
||||
const {height} = useReanimatedKeyboardAnimation()
|
||||
@@ -339,7 +334,6 @@ export function FlatListFooter({
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
@@ -352,7 +346,7 @@ export function FlatListFooter({
|
||||
a.pt_md,
|
||||
{
|
||||
paddingBottom: platform({
|
||||
ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0),
|
||||
ios: tokens.space.md + bottom,
|
||||
android: tokens.space.md + bottom + top,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -3,13 +3,12 @@ import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
@@ -99,7 +98,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: false,
|
||||
IS_NATIVEDialog: false,
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
@@ -254,18 +253,11 @@ export const InnerFlatList = React.forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS} from '#/env'
|
||||
|
||||
export function Header({
|
||||
renderLeft,
|
||||
@@ -36,7 +35,7 @@ export function Header({
|
||||
a.flex_row,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{minHeight: IS_LIQUID_GLASS ? 64 : 50},
|
||||
{minHeight: 50},
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
@@ -45,15 +44,11 @@ export function Header({
|
||||
style,
|
||||
]}>
|
||||
{renderLeft && (
|
||||
<View style={[a.absolute, {left: IS_LIQUID_GLASS ? 12 : 6}]}>
|
||||
{renderLeft()}
|
||||
</View>
|
||||
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
|
||||
)}
|
||||
{children}
|
||||
{renderRight && (
|
||||
<View style={[a.absolute, {right: IS_LIQUID_GLASS ? 12 : 6}]}>
|
||||
{renderRight()}
|
||||
</View>
|
||||
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useCallback} from 'react'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
|
||||
import {IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* If we're calling a system API like the image picker that opens a sheet
|
||||
@@ -9,7 +9,7 @@ import {IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
*/
|
||||
export function useSheetWrapper() {
|
||||
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
|
||||
if (IS_IOS && !IS_LIQUID_GLASS) {
|
||||
if (IS_IOS) {
|
||||
const entry = SystemBars.pushStackEntry({
|
||||
style: {
|
||||
statusBar: 'light',
|
||||
|
||||
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: DialogControlProps['close']
|
||||
isNativeDialog: boolean
|
||||
IS_NATIVEDialog: boolean
|
||||
nativeSnapPoint: BottomSheetSnapPoint
|
||||
disableDrag: boolean
|
||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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'
|
||||
|
||||
+24
-23
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo} from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -71,11 +72,11 @@ export function Link({
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = useMemo(() => {
|
||||
const href = React.useMemo(() => {
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
@@ -210,7 +211,7 @@ export function Description({
|
||||
description,
|
||||
...rest
|
||||
}: {description?: string} & Partial<RichTextProps>) {
|
||||
const rt = useMemo(() => {
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
@@ -277,7 +278,7 @@ function SaveButtonInner({
|
||||
pin?: boolean
|
||||
text?: boolean
|
||||
} & Partial<ButtonProps>) {
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
useAddSavedFeedsMutation()
|
||||
@@ -287,13 +288,13 @@ function SaveButtonInner({
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
|
||||
const savedFeedConfig = useMemo(() => {
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
|
||||
const toggleSave = useCallback(
|
||||
const toggleSave = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -310,17 +311,17 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(l`Failed to update feeds`, 'xmark')
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
},
|
||||
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
)
|
||||
|
||||
const onPromptRemoveFeed = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -333,13 +334,11 @@ function SaveButtonInner({
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={l`Add this feed to your feeds`}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={(e: GestureResponderEvent) =>
|
||||
savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e)
|
||||
}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||
{...buttonProps}>
|
||||
{savedFeedConfig ? (
|
||||
<>
|
||||
@@ -350,7 +349,7 @@ function SaveButtonInner({
|
||||
)}
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Unpin feed</Trans>
|
||||
<Trans>Unpin Feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -359,7 +358,7 @@ function SaveButtonInner({
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Pin feed</Trans>
|
||||
<Trans>Pin Feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -368,10 +367,12 @@ function SaveButtonInner({
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
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`}
|
||||
title={_(msg`Remove from your feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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'
|
||||
@@ -28,7 +21,6 @@ import {type SeenPost} from '#/state/userActionHistory'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
@@ -160,7 +152,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = useMemo(() => {
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
@@ -233,54 +225,67 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((dismissedDid: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
const onDismiss = React.useCallback((dismissedDid: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(dismissedDid))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(dismissedDid)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: data?.recId})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did) && profile.did !== did) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -296,9 +301,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -320,36 +327,46 @@ export function SuggestedFollowsHome() {
|
||||
error: suggestionsError,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((did: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring experimental profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: Array<{
|
||||
actor: bsky.profile.AnyProfileView
|
||||
recId?: number
|
||||
}> = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: undefined})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did)) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
@@ -357,19 +374,19 @@ export function SuggestedFollowsHome() {
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -388,6 +405,7 @@ export function SuggestedFollowsHome() {
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -397,14 +415,18 @@ export function ProfileGrid({
|
||||
error,
|
||||
profiles,
|
||||
totalProfileCount,
|
||||
recId,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
dismissingDids,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
totalProfileCount?: number
|
||||
recId?: number
|
||||
error: Error | null
|
||||
dismissingDids?: Set<string>
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
@@ -441,18 +463,18 @@ export function ProfileGrid({
|
||||
|
||||
const profilesToShow = profiles.slice(0, maxLength)
|
||||
profilesToShow.forEach((profile, index) => {
|
||||
if (!seenProfilesRef.current.has(profile.actor.did)) {
|
||||
seenProfilesRef.current.add(profile.actor.did)
|
||||
if (!seenProfilesRef.current.has(profile.did)) {
|
||||
seenProfilesRef.current.add(profile.did)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext,
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext])
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
|
||||
|
||||
// For profile header, fire when isVisible becomes true
|
||||
useEffect(() => {
|
||||
@@ -518,15 +540,8 @@ export function ProfileGrid({
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<Animated.View
|
||||
key={profile.actor.did}
|
||||
layout={native(
|
||||
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
|
||||
Easing.out(Easing.exp),
|
||||
),
|
||||
)}
|
||||
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
|
||||
// for web, as the cards are static, not in a list
|
||||
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
|
||||
key={profile.did}
|
||||
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
@@ -535,17 +550,22 @@ export function ProfileGrid({
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
{
|
||||
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||
transitionProperty: 'opacity',
|
||||
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||
},
|
||||
]}>
|
||||
<ProfileCard.Link
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
ax.metric('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -561,14 +581,14 @@ export function ProfileGrid({
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss(profile.actor.did)
|
||||
onDismiss(profile.did)
|
||||
ax.metric('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
recId: profile.recId,
|
||||
suggestedDid: profile.did,
|
||||
recId,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
@@ -601,18 +621,18 @@ export function ProfileGrid({
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
@@ -624,7 +644,7 @@ export function ProfileGrid({
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
@@ -635,9 +655,9 @@ export function ProfileGrid({
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -706,37 +726,35 @@ export function ProfileGrid({
|
||||
|
||||
<FollowDialogWithoutGuide control={followDialogControl} />
|
||||
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -777,7 +795,7 @@ export function SuggestedFeeds() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const feeds = useMemo(() => {
|
||||
const feeds = React.useMemo(() => {
|
||||
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||
|
||||
if (!data) return items
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/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'
|
||||
@@ -206,8 +205,9 @@ function KnownFollowersInner({
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans> // only 2
|
||||
</Trans>
|
||||
) : (
|
||||
// only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/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/core/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
moderateUserList,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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,8 +1,7 @@
|
||||
import {memo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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/react/macro'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -50,11 +50,7 @@ export function Embed({
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.presentation === 'gif' ? (
|
||||
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
) : (
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
)}
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
</Outer>
|
||||
)
|
||||
} else if (
|
||||
@@ -85,29 +81,11 @@ export function ImageItem({
|
||||
alt,
|
||||
children,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
thumbnail: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint="">
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<Image
|
||||
@@ -125,7 +103,7 @@ export function ImageItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
|
||||
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -147,6 +125,22 @@ export function VideoItem({
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
}) {
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -163,7 +157,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {msg, Trans} from '@lingui/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'
|
||||
@@ -273,8 +272,7 @@ export function ContainerItem({
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -312,8 +310,7 @@ export function Group({children, style}: GroupProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user