Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b7c863835 | |||
| 1ead06f6ff | |||
| f97a74e974 | |||
| 1ae77aa997 | |||
| 7cec490cef | |||
| 1281eb01e6 | |||
| 4339585b27 | |||
| 30c9ea5789 | |||
| afbade6f6f | |||
| 1782a65174 | |||
| 7fd218c97d | |||
| 8446efb739 | |||
| b84f28f884 | |||
| 7dc3b4fa8e | |||
| 37a82761f5 | |||
| 612a778361 | |||
| 162a78e1ae | |||
| c42502fdcb | |||
| 8b1c47a499 | |||
| c2fd87bd8b | |||
| d247a65683 | |||
| a95b877ea3 | |||
| f614bf650d | |||
| 5ee667f307 | |||
| 9c29b2867a | |||
| 9746dd8e4c | |||
| 8e2a5abfff | |||
| 6cdc1fe2b5 | |||
| 9bbcb472ef | |||
| 7c9f05a2af | |||
| d4357b2cb8 | |||
| 576761a645 | |||
| 8a1f8997fe | |||
| 6cb88ce1f3 | |||
| 65c4b7833d | |||
| 9b24d75c9c | |||
| 398df1a4b5 | |||
| db9c2a1596 | |||
| 00816b70dc | |||
| 49272be36b | |||
| 85ffc77983 | |||
| 95bede2dcb | |||
| e656fe688d | |||
| db1f45de92 | |||
| bc61314c41 | |||
| 1850042c37 | |||
| 782edbf281 | |||
| 73b096e443 | |||
| 9b0f0a8d24 | |||
| 9ec06971af | |||
| 75242b9b1f | |||
| 21e53b226d | |||
| 02c2a9ea7c | |||
| cb7e2ab976 | |||
| 067c6dacc9 | |||
| 9b8c46cdb7 |
+3
-1
@@ -110,7 +110,9 @@ google-services.json
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
src/locale/locales/**/*.js
|
||||
src/locale/locales/**/messages.js
|
||||
src/locale/locales/**/messages.mjs
|
||||
src/locale/locales/**/messages.ts
|
||||
|
||||
# local builds
|
||||
*.apk
|
||||
|
||||
@@ -46,6 +46,7 @@ src/
|
||||
├── alf/ # Design system (ALF) - themes, atoms, tokens
|
||||
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
|
||||
├── screens/ # Full-page screen components (newer pattern)
|
||||
├── features/ # Macro-features that bridge components/screens
|
||||
├── view/
|
||||
│ ├── screens/ # Full-page screens (legacy location)
|
||||
│ ├── com/ # Reusable view components
|
||||
@@ -60,6 +61,121 @@ src/
|
||||
└── Navigation.tsx # Main navigation configuration
|
||||
```
|
||||
|
||||
### Project Structure in Depth
|
||||
|
||||
When building new things, follow these guidelines for where to put code.
|
||||
|
||||
#### Components vs Screens vs Features
|
||||
|
||||
**Components** are reusable UI elements that are not full screens. Should be
|
||||
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
|
||||
these in `/components` if they are shared across screens.
|
||||
|
||||
**Screens** are full-page components that represent a route in the app. They
|
||||
often contain multiple components and handle layout for a page. New screens
|
||||
should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
**Features** are higher-level modules that may include context, data fetching,
|
||||
components, and utilities related to a specific feature e.g.
|
||||
`/features/liveNow`. They don't neatly fit into components or screens and often
|
||||
span multiple screens. This is an optional pattern for organizing complex
|
||||
features.
|
||||
|
||||
#### Legacy Directories
|
||||
|
||||
For the most part, avoid writing new files into the `/view` directory and
|
||||
subdirectories. This is the older pattern for organizing screens and components,
|
||||
and it has become a bit disorganized over time. New development should go into
|
||||
`/screens`, `/components`, and `/features`.
|
||||
|
||||
#### State
|
||||
|
||||
The `/state` directory is where we've historically put all our data fetching and
|
||||
state management logic. This is perfectly fine, but for new features, consider
|
||||
organizing state logic closer to the components that use it, either within a
|
||||
feature directory or co-located with a screen. The key is to keep related code
|
||||
together and avoid having "god files" with too much unrelated logic.
|
||||
|
||||
#### Lib
|
||||
|
||||
The `/lib` directory is for utilities and helpers that don't fit into other
|
||||
categories. This can include things like API clients, formatting functions,
|
||||
constants, and other shared logic.
|
||||
|
||||
#### Top Level Directories
|
||||
|
||||
Avoid writing new top-level subdirectories within `/src`. We've done this for a
|
||||
few things in the past that, but we have stronger patterns now. Examples:
|
||||
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
|
||||
better classified within `/features`. We will probably migrate these things
|
||||
eventually.
|
||||
|
||||
### File and Directory Naming Conventions
|
||||
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
@@ -271,7 +387,8 @@ import * as TextField from '#/components/forms/TextField'
|
||||
All user-facing strings must be wrapped for translation using Lingui.
|
||||
|
||||
```tsx
|
||||
import {msg, Trans, plural} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function MyComponent() {
|
||||
|
||||
@@ -39,7 +39,6 @@ appId: xyz.blueskyweb.app
|
||||
id: "editListNameInput"
|
||||
- eraseText
|
||||
- inputText: "Bad Ppl"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "editListDescriptionInput"
|
||||
- eraseText
|
||||
|
||||
@@ -35,9 +35,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-timeline-moveDown"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
@@ -55,9 +58,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- tapOn:
|
||||
label: "Tap on down arrow"
|
||||
id: "feed-feed-moveDown"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
|
||||
@@ -15,6 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
|
||||
@@ -26,6 +26,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "report:details"
|
||||
- inputText: "This is a test report"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
+23
-9
@@ -3,15 +3,29 @@ appId: xyz.blueskyweb.app
|
||||
- launchApp:
|
||||
appId: "xyz.blueskyweb.app"
|
||||
clearState: true
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "http://localhost:8081"
|
||||
- waitForAnimationToEnd
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
- swipe:
|
||||
from: "Bluesky"
|
||||
direction: DOWN
|
||||
duration: 100
|
||||
arguments:
|
||||
"-EXDevMenuIsOnboardingFinished": true
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: 'Open in "Bluesky"'
|
||||
commands:
|
||||
- tapOn: Open
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- tapOn: 'http://localhost:8081'
|
||||
- runFlow:
|
||||
label: "Dismiss Expo dev menu"
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- back
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
requestPermission: jest.fn(),
|
||||
onForegroundEvent: jest.fn(),
|
||||
setBadgeCount: jest.fn(),
|
||||
displayNotification: jest.fn(),
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const CameraRoll = {
|
||||
getPhotos: jest.fn().mockResolvedValue({
|
||||
edges: [
|
||||
{node: {image: {uri: 'path/to/image1.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image2.jpg'}}},
|
||||
{node: {image: {uri: 'path/to/image3.jpg'}}},
|
||||
],
|
||||
}),
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default {
|
||||
configure: jest.fn().mockResolvedValue(0),
|
||||
finish: jest.fn(),
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
export default {}
|
||||
@@ -1,10 +0,0 @@
|
||||
jest.mock('rn-fetch-blob', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
fs: {
|
||||
unlink: jest.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
export const DropdownMenu = jest.fn().mockImplementation(() => {})
|
||||
export const create = jest.fn().mockImplementation(() => {})
|
||||
@@ -1,4 +1,5 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
import {
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
parseStarterPackUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {messages} from '#/locale/locales/en/messages'
|
||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
@@ -202,6 +204,9 @@ describe('enforceLen', () => {
|
||||
})
|
||||
|
||||
describe('cleanError', () => {
|
||||
// cleanError uses lingui
|
||||
i18n.loadAndActivate({locale: 'en', messages})
|
||||
|
||||
const inputs = [
|
||||
'TypeError: Network request failed',
|
||||
'Error: Aborted',
|
||||
@@ -327,6 +332,7 @@ describe('shortenLinks', () => {
|
||||
expect(outputRT.text).toEqual(outputs[i][0])
|
||||
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
|
||||
for (let j = 0; j < outputs[i][1].length; j++) {
|
||||
// @ts-expect-error whatever
|
||||
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -35,6 +35,13 @@ module.exports = function (_config) {
|
||||
|
||||
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
|
||||
|
||||
const IOS_ICON_FILE =
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: IS_TESTFLIGHT
|
||||
? './assets/app-icons/ios_icon_testflight.icon'
|
||||
: './assets/app-icons/ios_icon_default.icon'
|
||||
|
||||
return {
|
||||
expo: {
|
||||
version: VERSION,
|
||||
@@ -55,10 +62,7 @@ module.exports = function (_config) {
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
icon:
|
||||
PLATFORM === 'web' // web build doesn't like .icon files
|
||||
? './assets/app-icons/ios_icon_default_next.png'
|
||||
: './assets/app-icons/ios_icon_default.icon',
|
||||
icon: IOS_ICON_FILE,
|
||||
infoPlist: {
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
@@ -112,7 +116,6 @@ module.exports = function (_config) {
|
||||
'zh-Hans',
|
||||
'zh-Hant',
|
||||
],
|
||||
UIDesignRequiresCompatibility: true,
|
||||
},
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
entitlements: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"value" : "overlay"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "screen"
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : "screen"
|
||||
}
|
||||
],
|
||||
"blur-material-specializations" : [
|
||||
{
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 0.5
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : null
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "TestFlight notice.png",
|
||||
"name" : "TestFlight notice"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"position" : {
|
||||
"scale" : 0.4,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
350
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"translucency-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : "none",
|
||||
"glass" : false,
|
||||
"image-name" : "iOS transparent.png",
|
||||
"name" : "iOS transparent"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
+1
-1
@@ -16,7 +16,7 @@ module.exports = function (api) {
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'macros',
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
function detectMime(buf: Buffer): string {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
<img
|
||||
{...others}
|
||||
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
|
||||
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
|
||||
}
|
||||
|
||||
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
@@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
|
||||
// Return the cleaned URL
|
||||
return pongo2.AsValue(parsedURL.String()), nil
|
||||
}
|
||||
|
||||
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||
urlStr := in.String()
|
||||
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
{% endfor %}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{% else %}
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif %}
|
||||
<meta name="twitter:label1" content="Posted At">
|
||||
|
||||
+1
-1
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
|
||||
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
|
||||
|
||||
- TextEncoder / TextDecoder
|
||||
- react-native-url-polyfill
|
||||
- Array#findLast (on web)
|
||||
- setImmediate (on web)
|
||||
|
||||
### Sentry sourcemaps
|
||||
|
||||
|
||||
+21
-6
@@ -73,7 +73,7 @@ import { Text } from "react-native";
|
||||
```jsx
|
||||
// After
|
||||
import { Text } from "react-native";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
<Text><Trans>Hello World</Trans></Text>
|
||||
```
|
||||
@@ -90,18 +90,33 @@ const text = "Hello World";
|
||||
```
|
||||
In this case, you can use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
const { _ } = useLingui();
|
||||
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/macro";
|
||||
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
|
||||
|
||||
```jsx
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
const { t } = useLingui();
|
||||
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
|
||||
```
|
||||
|
||||
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
|
||||
```jsx
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
// not ideal - t only gets called once at module evaluation time
|
||||
const text = t`Hello World`;
|
||||
|
||||
// however, this is suitable for strings that are ephemeral:
|
||||
function sayHello() {
|
||||
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
|
||||
}
|
||||
```
|
||||
|
||||
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
|
||||
@@ -121,7 +136,7 @@ So the workflow is as follows:
|
||||
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
|
||||
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
const welcomeMessage = msg`Welcome!`;
|
||||
|
||||
@@ -9,6 +9,14 @@ values.
|
||||
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
|
||||
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
|
||||
|
||||
### Running on Android
|
||||
|
||||
You will need to allow your device access to the port that the mock server is running on.
|
||||
|
||||
```
|
||||
adb reverse tcp:3000 tcp:3000
|
||||
```
|
||||
|
||||
### Running Maestro tests
|
||||
|
||||
- In one tab, run `yarn e2e:mock-server`
|
||||
|
||||
@@ -23,8 +23,6 @@ export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party/**',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
|
||||
+2
-2
@@ -1,7 +1,5 @@
|
||||
/* global jest */
|
||||
import 'react-native-gesture-handler/jestSetup'
|
||||
// IMPORTANT: this is what's used in the native runtime
|
||||
import 'react-native-url-polyfill/auto'
|
||||
|
||||
import {configure} from '@testing-library/react-native'
|
||||
|
||||
@@ -36,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => {
|
||||
jest.mock('expo-file-system/legacy', () => ({
|
||||
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
|
||||
deleteAsync: jest.fn(),
|
||||
moveAsync: jest.fn().mockResolvedValue(undefined),
|
||||
createDownloadResumable: jest.fn(),
|
||||
}))
|
||||
|
||||
@@ -45,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({
|
||||
}),
|
||||
SaveFormat: {
|
||||
JPEG: 'jpeg',
|
||||
WEBP: 'webp',
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** @type {import('@lingui/conf').LinguiConfig} */
|
||||
module.exports = {
|
||||
import {defineConfig} from '@lingui/cli'
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
locales: [
|
||||
'en',
|
||||
'an',
|
||||
@@ -49,5 +51,5 @@ module.exports = {
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: 'po',
|
||||
}
|
||||
compileNamespace: 'ts',
|
||||
})
|
||||
+2
@@ -48,6 +48,8 @@ class BottomSheetModule : Module() {
|
||||
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ public class BottomSheetModule: Module {
|
||||
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
|
||||
view.preventExpansion = prop
|
||||
}
|
||||
|
||||
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
|
||||
view.sourceViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
var preventDismiss = false
|
||||
var preventExpansion = false
|
||||
var cornerRadius: CGFloat?
|
||||
var sourceViewTag: Int?
|
||||
var minHeight = 0.0
|
||||
var maxHeight: CGFloat! {
|
||||
didSet {
|
||||
@@ -135,6 +136,15 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
sheetVc.view.addSubview(innerView)
|
||||
|
||||
if #available(iOS 26.0, *),
|
||||
let tag = self.sourceViewTag,
|
||||
let bridge = self.appContext?.reactBridge,
|
||||
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
|
||||
sheetVc.preferredTransition = .zoom { _ in
|
||||
return sourceView
|
||||
}
|
||||
}
|
||||
|
||||
self.sheetVc = sheetVc
|
||||
self.isOpening = true
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ class SheetViewController: UIViewController {
|
||||
return
|
||||
}
|
||||
|
||||
// On iOS 26, the floaty sheet presentation adds the device bottom safe area
|
||||
// on top of the custom detent value, creating visible padding inside the pill.
|
||||
// Subtract it so the pill height matches our actual content.
|
||||
var bottomSafeAreaAdjustment: CGFloat = 0
|
||||
if #available(iOS 26.0, *) {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
bottomSafeAreaAdjustment = window.safeAreaInsets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
let adjustedHeight = contentHeight - bottomSafeAreaAdjustment
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
if contentHeight > screenHeight - 100 {
|
||||
sheet.detents = [
|
||||
@@ -36,7 +49,7 @@ class SheetViewController: UIViewController {
|
||||
} else {
|
||||
sheet.detents = [
|
||||
.custom { _ in
|
||||
return contentHeight
|
||||
return adjustedHeight
|
||||
}
|
||||
]
|
||||
if !preventExpansion {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {ColorValue, NativeSyntheticEvent} from 'react-native'
|
||||
import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
|
||||
|
||||
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
|
||||
|
||||
@@ -25,6 +24,7 @@ export interface BottomSheetViewProps {
|
||||
backgroundColor?: ColorValue
|
||||
containerBackgroundColor?: ColorValue
|
||||
disableDrag?: boolean
|
||||
sourceViewTag?: number
|
||||
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
Platform,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -21,8 +22,6 @@ import {
|
||||
Context as PortalContext,
|
||||
} from './BottomSheetPortal'
|
||||
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
ref: React.RefObject<any>
|
||||
@@ -94,6 +93,7 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
|
||||
let extraStyles
|
||||
if (IS_IOS15 && this.state.viewHeight) {
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
const {viewHeight} = this.state
|
||||
const cornerRadius = this.props.cornerRadius ?? 0
|
||||
if (viewHeight < screenHeight / 2) {
|
||||
@@ -154,6 +154,7 @@ function BottomSheetNativeComponentInner({
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
|
||||
+18
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.117.0",
|
||||
"version": "1.118.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -16,6 +16,13 @@
|
||||
"expo-image-picker"
|
||||
]
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native-reanimated",
|
||||
"@sentry/react-native",
|
||||
"react-native-pager-view"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -59,7 +66,7 @@
|
||||
"intl:extract": "lingui extract --clean --locale en",
|
||||
"intl:extract:all": "lingui extract --clean",
|
||||
"intl:compile": "lingui compile",
|
||||
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
|
||||
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.ts ] || yarn intl:compile",
|
||||
"intl:pull": "crowdin download translations --verbose -b main",
|
||||
"intl:push": "crowdin push translations --verbose -b main",
|
||||
"intl:push-sources": "crowdin push sources --verbose -b main",
|
||||
@@ -73,13 +80,15 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.21",
|
||||
"@atproto/api": "^0.19.3",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.7",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/expo-translate-text": "^0.2.7",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
"@expo/webpack-config": "^19.0.1",
|
||||
@@ -97,7 +106,8 @@
|
||||
"@growthbook/growthbook-react": "^1.6.5",
|
||||
"@haileyok/bluesky-video": "0.3.2",
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/react": "^4.14.1",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
@@ -124,15 +134,13 @@
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^20.14.3",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
"await-lock": "^2.2.2",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"bcp-47": "^2.1.0",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54.0.27",
|
||||
@@ -164,14 +172,12 @@
|
||||
"expo-sms": "^14.0.7",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -202,7 +208,6 @@
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -212,7 +217,6 @@
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
@@ -221,6 +225,7 @@
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"setimmediate": "^1.0.5",
|
||||
"sonner": "^2.0.7",
|
||||
"sonner-native": "^0.21.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
@@ -236,8 +241,8 @@
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@expo/config-plugins": "~54.0.1",
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
@@ -252,7 +257,6 @@
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
"babel-preset-expo": "~54.0.0",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
diff --git a/node_modules/@lingui/core/dist/index.mjs b/node_modules/@lingui/core/dist/index.mjs
|
||||
index 9759736..881f67b 100644
|
||||
--- a/node_modules/@lingui/core/dist/index.mjs
|
||||
+++ b/node_modules/@lingui/core/dist/index.mjs
|
||||
@@ -1,4 +1,4 @@
|
||||
-import unraw from 'unraw';
|
||||
+import { unraw } from 'unraw';
|
||||
import { compileMessage } from '@lingui/message-utils/compileMessage';
|
||||
|
||||
const isString = (s) => typeof s === "string";
|
||||
+7
-4
@@ -11,7 +11,7 @@ import {
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottom
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {s} from '#/lib/styles'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
@@ -179,9 +180,11 @@ function InnerApp() {
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
<TranslateOnDeviceProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
+6
-3
@@ -4,12 +4,13 @@ import './style.css'
|
||||
|
||||
import {Fragment, useEffect, useState} from 'react'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
@@ -154,8 +155,10 @@ function InnerApp() {
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
<TranslateOnDeviceProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
|
||||
+27
-4
@@ -2,7 +2,7 @@ import {type JSX, useCallback, useRef} from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {i18n, type MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {
|
||||
type BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
@@ -138,7 +138,7 @@ import {
|
||||
} from '#/components/dialogs/EmailDialog'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -685,10 +685,30 @@ function screenOptions(t: Theme) {
|
||||
function HomeTabNavigator() {
|
||||
const t = useTheme()
|
||||
|
||||
const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS
|
||||
? ({
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerTitle: '',
|
||||
headerBackVisible: false,
|
||||
scrollEdgeEffects: {
|
||||
top: 'soft',
|
||||
},
|
||||
} as const)
|
||||
: {}
|
||||
|
||||
return (
|
||||
<HomeTab.Navigator screenOptions={screenOptions(t)} initialRouteName="Home">
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={BLURRED_SCROLL_EDGE_EFFECT}
|
||||
/>
|
||||
<HomeTab.Screen
|
||||
name="Start"
|
||||
getComponent={() => HomeScreen}
|
||||
options={BLURRED_SCROLL_EDGE_EFFECT}
|
||||
/>
|
||||
{commonScreens(HomeTab as typeof Flat)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -1008,6 +1028,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// temp, just testing
|
||||
void ax.features.enabled(ax.features.AATest)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
SupportCode,
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
} from 'react'
|
||||
import {Dimensions, View} from 'react-native'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
|
||||
@@ -9,4 +9,6 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Do not import runtime code into this file
|
||||
*/
|
||||
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
@@ -647,6 +649,32 @@ export type Events = {
|
||||
tab: string
|
||||
}
|
||||
|
||||
'search:query': {
|
||||
source: 'typed' | 'history' | 'autocomplete'
|
||||
}
|
||||
|
||||
'search:results:loaded': {
|
||||
tab: 'top' | 'latest' | 'people' | 'feeds'
|
||||
initialCount: number
|
||||
}
|
||||
|
||||
'search:result:press': {
|
||||
tab?: 'top' | 'latest' | 'people' | 'feeds'
|
||||
resultType: 'post' | 'profile' | 'feed'
|
||||
position: number
|
||||
uri: string
|
||||
}
|
||||
|
||||
'search:recent:press': {
|
||||
profileDid: string
|
||||
position: number
|
||||
}
|
||||
|
||||
'search:autocomplete:press': {
|
||||
profileDid: string
|
||||
position: number
|
||||
}
|
||||
|
||||
'progressGuide:hide': {}
|
||||
'progressGuide:followDialog:open': {}
|
||||
|
||||
@@ -679,6 +707,17 @@ export type Events = {
|
||||
targetLanguage: string
|
||||
textLength: number
|
||||
}
|
||||
'translate:result': {
|
||||
method: 'on-device' | 'google-translate' | 'fallback-alert'
|
||||
os: Platform['OS']
|
||||
sourceLanguage: string | null
|
||||
targetLanguage: string
|
||||
}
|
||||
'translate:override': {
|
||||
os: Platform['OS']
|
||||
sourceLanguage: string
|
||||
targetLanguage: string
|
||||
}
|
||||
|
||||
'verification:create': {}
|
||||
'verification:revoke': {}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {isJwtExpired} from '#/lib/jwt'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -5,7 +5,7 @@ import Animated, {
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext} from 'react'
|
||||
|
||||
import {
|
||||
type ContextType,
|
||||
@@ -6,17 +6,17 @@ import {
|
||||
type MenuContextType,
|
||||
} from '#/components/ContextMenu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
export const Context = createContext<ContextType | null>(null)
|
||||
Context.displayName = 'ContextMenuContext'
|
||||
|
||||
export const MenuContext = React.createContext<MenuContextType | null>(null)
|
||||
export const MenuContext = createContext<MenuContextType | null>(null)
|
||||
MenuContext.displayName = 'ContextMenuMenuContext'
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||
export const ItemContext = createContext<ItemContextType | null>(null)
|
||||
ItemContext.displayName = 'ContextMenuItemContext'
|
||||
|
||||
export function useContextMenuContext() {
|
||||
const context = React.useContext(Context)
|
||||
const context = useContext(Context)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -28,7 +28,7 @@ export function useContextMenuContext() {
|
||||
}
|
||||
|
||||
export function useContextMenuMenuContext() {
|
||||
const context = React.useContext(MenuContext)
|
||||
const context = useContext(MenuContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -40,7 +40,7 @@ export function useContextMenuMenuContext() {
|
||||
}
|
||||
|
||||
export function useContextMenuItemContext() {
|
||||
const context = React.useContext(ItemContext)
|
||||
const context = useContext(ItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import {KeyboardEvents} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
@@ -35,12 +36,13 @@ import Animated, {
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {captureRef} from 'react-native-view-shot'
|
||||
import {Image, type ImageErrorEventData} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
@@ -81,9 +83,9 @@ export {
|
||||
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||
|
||||
const SPRING_IN: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 50,
|
||||
stiffness: 1100,
|
||||
mass: 0.75,
|
||||
damping: 300,
|
||||
stiffness: 1200,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
@@ -110,6 +112,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
const playHaptic = useHaptics()
|
||||
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null)
|
||||
const animationSV = useSharedValue(0)
|
||||
const translationSV = useSharedValue(0)
|
||||
const isFocused = useIsFocused()
|
||||
@@ -142,6 +145,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
({
|
||||
isOpen: !!measurement && isFocused,
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
animationSV,
|
||||
translationSV,
|
||||
mode,
|
||||
@@ -149,6 +153,8 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
setMeasurement(evt)
|
||||
setMode(mode)
|
||||
animationSV.set(withSpring(1, SPRING_IN))
|
||||
// reset return location
|
||||
returnLocationSV.set(null)
|
||||
},
|
||||
close: () => {
|
||||
animationSV.set(
|
||||
@@ -156,6 +162,9 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
if (finished) {
|
||||
hoverablesSV.set({})
|
||||
translationSV.set(0)
|
||||
// note: return location has to be reset on open,
|
||||
// rather than on close, otherwise there's a flicker
|
||||
// where the reanimated update is faster than the react render
|
||||
runOnJS(onCompletedClose)()
|
||||
}
|
||||
}),
|
||||
@@ -194,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
}) satisfies ContextType,
|
||||
[
|
||||
measurement,
|
||||
returnLocationSV,
|
||||
setMeasurement,
|
||||
onCompletedClose,
|
||||
isFocused,
|
||||
@@ -225,7 +235,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const insets = useSafeAreaInsets()
|
||||
const ref = useRef<View>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
@@ -237,23 +247,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
const open = useNonReactiveCallback(
|
||||
async (mode: 'full' | 'auxiliary-only') => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
const [measurement, capture] = await Promise.all([
|
||||
new Promise<Measurement>(resolve => {
|
||||
ref.current?.measureInWindow((x, y, width, height) =>
|
||||
resolve({
|
||||
x,
|
||||
y:
|
||||
y +
|
||||
platform({
|
||||
default: 0,
|
||||
android: topInset, // not included in measurement
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
measureView(ref.current, insets),
|
||||
captureRef(ref, {result: 'data-uri'}).catch(err => {
|
||||
logger.error(err instanceof Error ? err : String(err), {
|
||||
message: 'Failed to capture image of context menu trigger',
|
||||
@@ -262,16 +257,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
return '<failed capture>'
|
||||
}),
|
||||
])
|
||||
Keyboard.dismiss()
|
||||
setImage(capture)
|
||||
setPendingMeasurement({measurement, mode})
|
||||
if (measurement) {
|
||||
setPendingMeasurement({measurement, mode})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// after keyboard hides, the position might change - set a return location
|
||||
useEffect(() => {
|
||||
if (context.isOpen && context.measurement) {
|
||||
const hide = KeyboardEvents.addListener('keyboardDidHide', () => {
|
||||
measureView(ref.current, insets)
|
||||
.then(newMeasurement => {
|
||||
if (!newMeasurement || !context.measurement) return
|
||||
if (
|
||||
newMeasurement.x !== context.measurement.x ||
|
||||
newMeasurement.y !== context.measurement.y
|
||||
) {
|
||||
context.returnLocationSV.set({
|
||||
x: newMeasurement.x,
|
||||
y: newMeasurement.y,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
hide.remove()
|
||||
}
|
||||
}
|
||||
}, [context, insets])
|
||||
|
||||
const doubleTapGesture = useMemo(() => {
|
||||
return Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(() => open('auxiliary-only'))
|
||||
.onEnd(() => void open('auxiliary-only'))
|
||||
.runOnJS(true)
|
||||
}, [open])
|
||||
|
||||
@@ -360,6 +384,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
animation={animationSV}
|
||||
image={image}
|
||||
measurement={measurement}
|
||||
returnLocation={context.returnLocationSV}
|
||||
onDisplay={() => {
|
||||
if (pendingMeasurement) {
|
||||
context.open(
|
||||
@@ -384,6 +409,7 @@ function TriggerClone({
|
||||
animation,
|
||||
image,
|
||||
measurement,
|
||||
returnLocation,
|
||||
onDisplay,
|
||||
label,
|
||||
}: {
|
||||
@@ -391,14 +417,29 @@ function TriggerClone({
|
||||
animation: SharedValue<number>
|
||||
image: string
|
||||
measurement: Measurement
|
||||
returnLocation: SharedValue<{x: number; y: number} | null>
|
||||
onDisplay: () => void
|
||||
label: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedStyles = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translation.get() * animation.get()}],
|
||||
}))
|
||||
const animatedStyles = useAnimatedStyle(() => {
|
||||
const anim = animation.get()
|
||||
const ret = returnLocation.get()
|
||||
const returnOffsetX = ret
|
||||
? interpolate(anim, [0, 1], [ret.x - measurement.x, 0])
|
||||
: 0
|
||||
const returnOffsetY = ret
|
||||
? interpolate(anim, [0, 1], [ret.y - measurement.y, 0])
|
||||
: 0
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{translateX: returnOffsetX},
|
||||
{translateY: translation.get() * anim + returnOffsetY},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const handleError = useCallback(
|
||||
(evt: ImageErrorEventData) => {
|
||||
@@ -874,6 +915,25 @@ export function Divider() {
|
||||
)
|
||||
}
|
||||
|
||||
function measureView(view: View | null, insets: EdgeInsets) {
|
||||
if (!view) return Promise.resolve(null)
|
||||
return new Promise<Measurement>(resolve => {
|
||||
view?.measureInWindow((x, y, width, height) =>
|
||||
resolve({
|
||||
x,
|
||||
y:
|
||||
y +
|
||||
platform({
|
||||
default: 0,
|
||||
android: insets.top, // not included in measurement
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getHoveredHoverable(
|
||||
evt:
|
||||
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||
|
||||
@@ -49,6 +49,7 @@ export type ContextType = {
|
||||
translationSV: SharedValue<number>
|
||||
mode: 'full' | 'auxiliary-only'
|
||||
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
|
||||
returnLocationSV: SharedValue<{x: number; y: number} | null>
|
||||
close: () => void
|
||||
registerHoverable: (
|
||||
id: string,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
|
||||
|
||||
export const Context = createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
IS_NATIVEDialog: false,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
type ScrollView,
|
||||
ScrollView,
|
||||
type StyleProp,
|
||||
TextInput,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
type KeyboardAwareScrollViewRef,
|
||||
useKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
@@ -29,7 +25,7 @@ import {logger} from '#/logger'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||
import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -38,7 +34,7 @@ import {
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
@@ -154,7 +150,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: true,
|
||||
isNativeDialog: true,
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
@@ -166,7 +162,8 @@ export function Outer({
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={ref}
|
||||
cornerRadius={20}
|
||||
// device-bezel radius when undefined
|
||||
cornerRadius={IS_LIQUID_GLASS ? undefined : 20}
|
||||
backgroundColor={t.atoms.bg.backgroundColor}
|
||||
{...nativeOptions}
|
||||
onSnapPointChange={onSnapPointChange}
|
||||
@@ -181,6 +178,9 @@ 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,
|
||||
{
|
||||
paddingBottom: insets.bottom + insets.top,
|
||||
},
|
||||
IS_LIQUID_GLASS
|
||||
? a.pb_2xl
|
||||
: {paddingBottom: insets.bottom + insets.top},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
@@ -208,33 +208,17 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
|
||||
|
||||
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
|
||||
useKeyboardHandler(
|
||||
{
|
||||
onEnd: e => {
|
||||
'worklet'
|
||||
runOnJS(setKeyboardHeight)(e.height)
|
||||
},
|
||||
},
|
||||
[],
|
||||
)
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
|
||||
let paddingBottom = 0
|
||||
if (IS_IOS) {
|
||||
paddingBottom += keyboardHeight / 4
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.bottom + tokens.space.md
|
||||
}
|
||||
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
|
||||
paddingBottom = tokens.space._2xl
|
||||
} else {
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom =
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
if (isAtMaxSnapPoint) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
paddingBottom +=
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -250,18 +234,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
{paddingBottom},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
bottomOffset={30}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={IS_ANDROID ? onScroll : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -271,7 +258,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
</ScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -283,10 +270,15 @@ export const InnerFlatList = React.forwardRef<
|
||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
>(function InnerFlatList({footer, style, ...props}, ref) {
|
||||
>(function InnerFlatList(
|
||||
{headerOffset, footer, style, contentContainerStyle, ...props},
|
||||
ref,
|
||||
) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
|
||||
const onScroll = (e: ScrollEvent) => {
|
||||
'worklet'
|
||||
if (!IS_ANDROID) {
|
||||
@@ -304,19 +296,36 @@ export const InnerFlatList = React.forwardRef<
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
scrollIndicatorInsets={{top: headerOffset}}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
{...props}
|
||||
style={[a.h_full, style]}
|
||||
contentContainerStyle={[
|
||||
{paddingTop: headerOffset},
|
||||
android({
|
||||
paddingBottom: insets.top + insets.bottom + tokens.space.xl,
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
/>
|
||||
{footer}
|
||||
</ScrollProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {top, bottom} = useSafeAreaInsets()
|
||||
const {height} = useReanimatedKeyboardAnimation()
|
||||
@@ -330,6 +339,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
@@ -342,7 +352,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
a.pt_md,
|
||||
{
|
||||
paddingBottom: platform({
|
||||
ios: tokens.space.md + bottom,
|
||||
ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0),
|
||||
android: tokens.space.md + bottom + top,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
@@ -98,7 +99,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: false,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
@@ -253,11 +254,18 @@ export const InnerFlatList = React.forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.bottom_0,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS} from '#/env'
|
||||
|
||||
export function Header({
|
||||
renderLeft,
|
||||
@@ -35,7 +36,7 @@ export function Header({
|
||||
a.flex_row,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{minHeight: 50},
|
||||
{minHeight: IS_LIQUID_GLASS ? 64 : 50},
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
@@ -44,11 +45,15 @@ export function Header({
|
||||
style,
|
||||
]}>
|
||||
{renderLeft && (
|
||||
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
|
||||
<View style={[a.absolute, {left: IS_LIQUID_GLASS ? 12 : 6}]}>
|
||||
{renderLeft()}
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
{renderRight && (
|
||||
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
|
||||
<View style={[a.absolute, {right: IS_LIQUID_GLASS ? 12 : 6}]}>
|
||||
{renderRight()}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useCallback} from 'react'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
|
||||
import {IS_IOS} from '#/env'
|
||||
import {IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
|
||||
/**
|
||||
* If we're calling a system API like the image picker that opens a sheet
|
||||
@@ -9,7 +9,7 @@ import {IS_IOS} from '#/env'
|
||||
*/
|
||||
export function useSheetWrapper() {
|
||||
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
|
||||
if (IS_IOS) {
|
||||
if (IS_IOS && !IS_LIQUID_GLASS) {
|
||||
const entry = SystemBars.pushStackEntry({
|
||||
style: {
|
||||
statusBar: 'light',
|
||||
|
||||
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: DialogControlProps['close']
|
||||
IS_NATIVEDialog: boolean
|
||||
isNativeDialog: boolean
|
||||
nativeSnapPoint: BottomSheetSnapPoint
|
||||
disableDrag: boolean
|
||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import {useLayoutEffect, useRef} from 'react'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
type SharedValue,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Drag-to-reorder list. Items are absolutely positioned in a fixed-height
|
||||
* container and animated via Reanimated shared values on the UI thread.
|
||||
*
|
||||
* All positioning is driven by a `slots` map (key → index) and translateY
|
||||
* (no discrete `top` changes). On drag end the new slot assignment is
|
||||
* computed on the UI thread first, then React state is updated via runOnJS.
|
||||
*
|
||||
* See SortableList.web.tsx for the web implementation using pointer events.
|
||||
*/
|
||||
|
||||
interface SortableListProps<T> {
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onReorder: (data: T[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
/** Fixed row height used for position math. */
|
||||
itemHeight: number
|
||||
/** Ref to the parent Animated.ScrollView for auto-scroll. */
|
||||
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||
/** Scroll offset shared value from useScrollViewOffset. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_THRESHOLD = 50
|
||||
const AUTO_SCROLL_SPEED = 4
|
||||
|
||||
/**
|
||||
* Bundled into a single shared value so all fields update atomically
|
||||
* in one set() call on the UI thread.
|
||||
*/
|
||||
interface DragState {
|
||||
/** Maps each item key to its current slot index. */
|
||||
slots: Record<string, number>
|
||||
/** Key of the item being dragged, or '' when idle. */
|
||||
activeKey: string
|
||||
/** Slot the active item started in. */
|
||||
dragStartSlot: number
|
||||
}
|
||||
|
||||
export function SortableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
itemHeight,
|
||||
scrollRef,
|
||||
scrollOffset,
|
||||
}: SortableListProps<T>) {
|
||||
const t = useTheme()
|
||||
const state = useSharedValue<DragState>({
|
||||
slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])),
|
||||
activeKey: '',
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
const dragY = useSharedValue(0)
|
||||
|
||||
// Auto-scroll shared values
|
||||
const scrollCompensation = useSharedValue(0)
|
||||
const isGestureActive = useSharedValue(false)
|
||||
// We track scroll position ourselves because scrollOffset.get() lags
|
||||
// by one frame after scrollTo(), causing a feedback loop where the
|
||||
// frame callback keeps thinking the item is at the edge.
|
||||
const trackedScrollY = useSharedValue(0)
|
||||
|
||||
// For measuring list position within scroll content
|
||||
const listRef = useAnimatedRef<Animated.View>()
|
||||
const listContentOffset = useSharedValue(0)
|
||||
const viewportHeight = useSharedValue(0)
|
||||
const measureDone = useSharedValue(false)
|
||||
|
||||
// Sync slots when data changes externally (e.g. pin/unpin).
|
||||
// Skip after our own reorder — the worklet already set correct slots
|
||||
// on the UI thread, and a redundant JS-side set() would be wasteful.
|
||||
const skipNextSync = useRef(false)
|
||||
const currentKeys = data.map(item => keyExtractor(item)).join(',')
|
||||
useLayoutEffect(() => {
|
||||
if (skipNextSync.current) {
|
||||
skipNextSync.current = false
|
||||
return
|
||||
}
|
||||
const nextSlots: Record<string, number> = {}
|
||||
data.forEach((item, i) => {
|
||||
nextSlots[keyExtractor(item)] = i
|
||||
})
|
||||
state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
}, [currentKeys, data, keyExtractor, state, dragY])
|
||||
|
||||
const handleReorder = (sortedKeys: string[]) => {
|
||||
skipNextSync.current = true
|
||||
const byKey = new Map(data.map(item => [keyExtractor(item), item]))
|
||||
onReorder(sortedKeys.map(key => byKey.get(key)!))
|
||||
onDragEnd?.()
|
||||
}
|
||||
|
||||
// Auto-scroll: runs every frame while a gesture is active.
|
||||
useFrameCallback(() => {
|
||||
if (!isGestureActive.get()) return
|
||||
if (!scrollRef || !scrollOffset) return
|
||||
|
||||
const s = state.get()
|
||||
if (s.activeKey === '') return
|
||||
|
||||
// Measure list and scroll view on first frame of drag.
|
||||
// Use scrollOffset here (only once) since no lag has occurred yet.
|
||||
if (!measureDone.get()) {
|
||||
const scrollM = measure(
|
||||
scrollRef as unknown as AnimatedRef<Animated.View>,
|
||||
)
|
||||
const listM = measure(listRef)
|
||||
if (!scrollM || !listM) return
|
||||
trackedScrollY.set(scrollOffset.get())
|
||||
listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get())
|
||||
viewportHeight.set(scrollM.height)
|
||||
measureDone.set(true)
|
||||
}
|
||||
|
||||
const startSlot = s.dragStartSlot
|
||||
const currentDragY = dragY.get()
|
||||
|
||||
// Use trackedScrollY (not scrollOffset) to avoid the one-frame lag
|
||||
// after scrollTo() that causes a feedback loop.
|
||||
const scrollY = trackedScrollY.get()
|
||||
|
||||
// Item position relative to scroll viewport top.
|
||||
const itemContentY =
|
||||
listContentOffset.get() + startSlot * itemHeight + currentDragY
|
||||
const itemViewportY = itemContentY - scrollY
|
||||
const itemBottomViewportY = itemViewportY + itemHeight
|
||||
|
||||
let scrollDelta = 0
|
||||
if (itemViewportY < AUTO_SCROLL_THRESHOLD) {
|
||||
scrollDelta = -AUTO_SCROLL_SPEED
|
||||
} else if (
|
||||
itemBottomViewportY >
|
||||
viewportHeight.get() - AUTO_SCROLL_THRESHOLD
|
||||
) {
|
||||
scrollDelta = AUTO_SCROLL_SPEED
|
||||
}
|
||||
|
||||
if (scrollDelta === 0) return
|
||||
|
||||
// Don't scroll if the item is already at a list boundary.
|
||||
const effectiveSlotPos =
|
||||
(startSlot * itemHeight + currentDragY) / itemHeight
|
||||
if (scrollDelta < 0 && effectiveSlotPos <= 0) return
|
||||
if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return
|
||||
|
||||
// Don't scroll past the top.
|
||||
if (scrollDelta < 0 && scrollY <= 0) return
|
||||
|
||||
const newScrollY = Math.max(0, scrollY + scrollDelta)
|
||||
scrollTo(scrollRef, 0, newScrollY, false)
|
||||
trackedScrollY.set(newScrollY)
|
||||
scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY))
|
||||
})
|
||||
|
||||
// Render in stable key order so React never reorders native views.
|
||||
// On Android, native ViewGroup child reordering causes a visual flash.
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
const ka = keyExtractor(a)
|
||||
const kb = keyExtractor(b)
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
ref={listRef}
|
||||
style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}>
|
||||
{sortedData.map(item => {
|
||||
const key = keyExtractor(item)
|
||||
return (
|
||||
<SortableItem
|
||||
key={key}
|
||||
item={item}
|
||||
itemKey={key}
|
||||
itemCount={data.length}
|
||||
itemHeight={itemHeight}
|
||||
state={state}
|
||||
dragY={dragY}
|
||||
scrollCompensation={scrollCompensation}
|
||||
isGestureActive={isGestureActive}
|
||||
measureDone={measureDone}
|
||||
renderItem={renderItem}
|
||||
onCommitReorder={handleReorder}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableItem<T>({
|
||||
item,
|
||||
itemKey,
|
||||
itemCount,
|
||||
itemHeight,
|
||||
state,
|
||||
dragY,
|
||||
scrollCompensation,
|
||||
isGestureActive,
|
||||
measureDone,
|
||||
renderItem,
|
||||
onCommitReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: {
|
||||
item: T
|
||||
itemKey: string
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
state: Animated.SharedValue<DragState>
|
||||
dragY: Animated.SharedValue<number>
|
||||
scrollCompensation: SharedValue<number>
|
||||
isGestureActive: SharedValue<boolean>
|
||||
measureDone: SharedValue<boolean>
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onCommitReorder: (sortedKeys: string[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
const lastHapticSlot = useSharedValue(-1)
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
const s = state.get()
|
||||
const mySlot = s.slots[itemKey]
|
||||
state.set({...s, activeKey: itemKey, dragStartSlot: mySlot})
|
||||
dragY.set(0)
|
||||
scrollCompensation.set(0)
|
||||
isGestureActive.set(true)
|
||||
measureDone.set(false)
|
||||
lastHapticSlot.set(mySlot)
|
||||
if (onDragStart) {
|
||||
runOnJS(onDragStart)()
|
||||
}
|
||||
runOnJS(playHaptic)()
|
||||
})
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
const startSlot = state.get().dragStartSlot
|
||||
const minY = -startSlot * itemHeight
|
||||
const maxY = (itemCount - 1 - startSlot) * itemHeight
|
||||
// Include scroll compensation so the item tracks with auto-scroll.
|
||||
const effectiveY = e.translationY + scrollCompensation.get()
|
||||
const clampedY = Math.max(minY, Math.min(effectiveY, maxY))
|
||||
dragY.set(clampedY)
|
||||
|
||||
const currentSlot = Math.round(
|
||||
(startSlot * itemHeight + clampedY) / itemHeight,
|
||||
)
|
||||
const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
|
||||
if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
|
||||
lastHapticSlot.set(clampedSlot)
|
||||
runOnJS(playHaptic)('Light')
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
// Stop auto-scroll BEFORE the snap animation.
|
||||
isGestureActive.set(false)
|
||||
const startSlot = state.get().dragStartSlot
|
||||
const rawNewSlot = Math.round(
|
||||
(startSlot * itemHeight + dragY.get()) / itemHeight,
|
||||
)
|
||||
const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1))
|
||||
const snapOffset = (newSlot - startSlot) * itemHeight
|
||||
|
||||
// Animate to the target slot, then commit.
|
||||
dragY.set(
|
||||
withTiming(snapOffset, {duration: 200}, finished => {
|
||||
if (finished) {
|
||||
if (newSlot !== startSlot) {
|
||||
// Compute new slots on the UI thread so animated styles
|
||||
// reflect final positions before React re-renders.
|
||||
const cur = state.get()
|
||||
const sorted: string[] = new Array(itemCount)
|
||||
for (const key in cur.slots) {
|
||||
sorted[cur.slots[key]] = key
|
||||
}
|
||||
const movedKey = sorted[startSlot]
|
||||
sorted.splice(startSlot, 1)
|
||||
sorted.splice(newSlot, 0, movedKey)
|
||||
|
||||
const nextSlots: Record<string, number> = {}
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
nextSlots[sorted[i]] = i
|
||||
}
|
||||
|
||||
state.set({
|
||||
slots: nextSlots,
|
||||
activeKey: '',
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
dragY.set(0)
|
||||
runOnJS(onCommitReorder)(sorted)
|
||||
} else {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Reset if the gesture is cancelled without onEnd firing.
|
||||
.onFinalize(() => {
|
||||
'worklet'
|
||||
isGestureActive.set(false)
|
||||
if (state.get().activeKey === itemKey && dragY.get() === 0) {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// All vertical positioning is via translateY (no `top`). This avoids
|
||||
// discrete jumps when slots change — Reanimated smoothly animates from
|
||||
// the current translateY to the new target on every state transition.
|
||||
// On first mount we skip the animation so items appear instantly.
|
||||
const isFirstRender = useSharedValue(true)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const s = state.get()
|
||||
const mySlot = s.slots[itemKey]
|
||||
if (mySlot === undefined) {
|
||||
return {}
|
||||
}
|
||||
const baseY = mySlot * itemHeight
|
||||
|
||||
// Active item: follow the finger with a slight scale-up and shadow.
|
||||
if (s.activeKey === itemKey) {
|
||||
return {
|
||||
transform: [
|
||||
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
|
||||
{scale: withSpring(1.03)},
|
||||
],
|
||||
zIndex: 999,
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: withSpring(0.08),
|
||||
shadowRadius: withSpring(4),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(3),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Reset for non-active states. Without this, shadow props
|
||||
// set during dragging linger on the native view.
|
||||
const inactive = {
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowOpacity: withSpring(0),
|
||||
shadowRadius: withSpring(0),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(0),
|
||||
}),
|
||||
}
|
||||
|
||||
// Another item is being dragged — shift to make room.
|
||||
if (s.activeKey !== '') {
|
||||
isFirstRender.set(false)
|
||||
const currentDragPos = Math.round(
|
||||
(s.dragStartSlot * itemHeight + dragY.get()) / itemHeight,
|
||||
)
|
||||
const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1))
|
||||
|
||||
let offset = 0
|
||||
if (
|
||||
s.dragStartSlot < clampedPos &&
|
||||
mySlot > s.dragStartSlot &&
|
||||
mySlot <= clampedPos
|
||||
) {
|
||||
offset = -itemHeight
|
||||
} else if (
|
||||
s.dragStartSlot > clampedPos &&
|
||||
mySlot < s.dragStartSlot &&
|
||||
mySlot >= clampedPos
|
||||
) {
|
||||
offset = itemHeight
|
||||
}
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{translateY: withTiming(baseY + offset, {duration: 200})},
|
||||
{scale: withSpring(1)},
|
||||
],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
}
|
||||
|
||||
// Idle: sit at our slot. On first render use a direct value so items
|
||||
// don't animate from y=0. After any drag, use withTiming so the
|
||||
// shift→idle transition is smooth (no discrete jump).
|
||||
if (isFirstRender.get()) {
|
||||
isFirstRender.set(false)
|
||||
return {
|
||||
transform: [{translateY: baseY}, {scale: 1}],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
}
|
||||
})
|
||||
|
||||
const dragHandle = (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View
|
||||
testID="feed-drag-handle"
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_sm,
|
||||
a.py_md,
|
||||
web({cursor: 'grab'}),
|
||||
]}
|
||||
hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
|
||||
<GripIcon
|
||||
size="lg"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={web({pointerEvents: 'none'})}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{renderItem(item, dragHandle)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
|
||||
|
||||
/**
|
||||
* Web implementation of SortableList using pointer events.
|
||||
* See SortableList.tsx for the native version using gesture-handler + Reanimated.
|
||||
*/
|
||||
|
||||
interface SortableListProps<T> {
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
|
||||
onReorder: (data: T[]) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
/** Fixed row height used for position math. */
|
||||
itemHeight: number
|
||||
}
|
||||
|
||||
export function SortableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onReorder,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
itemHeight,
|
||||
}: SortableListProps<T>) {
|
||||
const t = useTheme()
|
||||
const [dragState, setDragState] = useState<{
|
||||
activeIndex: number
|
||||
currentY: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
|
||||
const getNewPosition = (state: {
|
||||
activeIndex: number
|
||||
currentY: number
|
||||
startY: number
|
||||
}) => {
|
||||
const translationY = state.currentY - state.startY
|
||||
const rawNewPos = Math.round(
|
||||
(state.activeIndex * itemHeight + translationY) / itemHeight,
|
||||
)
|
||||
return Math.max(0, Math.min(rawNewPos, data.length - 1))
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!dragState) return
|
||||
e.preventDefault()
|
||||
setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (!dragState) return
|
||||
const newPos = getNewPosition(dragState)
|
||||
if (newPos !== dragState.activeIndex) {
|
||||
const next = [...data]
|
||||
const [moved] = next.splice(dragState.activeIndex, 1)
|
||||
next.splice(newPos, 0, moved)
|
||||
onReorder(next)
|
||||
}
|
||||
setDragState(null)
|
||||
onDragEnd?.()
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY})
|
||||
onDragStart?.()
|
||||
}
|
||||
|
||||
const newPos = dragState ? getNewPosition(dragState) : -1
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{height: data.length * itemHeight, position: 'relative'},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
// @ts-expect-error web-only pointer events
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}>
|
||||
{data.map((item, index) => {
|
||||
const isActive = dragState?.activeIndex === index
|
||||
|
||||
// Clamp translation so the item stays within list bounds.
|
||||
const rawTranslationY = isActive
|
||||
? dragState.currentY - dragState.startY
|
||||
: 0
|
||||
const translationY = isActive
|
||||
? Math.max(
|
||||
-index * itemHeight,
|
||||
Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight),
|
||||
)
|
||||
: 0
|
||||
|
||||
// Non-dragged items shift to make room for the dragged item.
|
||||
let offset = 0
|
||||
if (dragState && !isActive) {
|
||||
const orig = dragState.activeIndex
|
||||
if (orig < newPos && index > orig && index <= newPos) {
|
||||
offset = -itemHeight
|
||||
} else if (orig > newPos && index < orig && index >= newPos) {
|
||||
offset = itemHeight
|
||||
}
|
||||
}
|
||||
|
||||
const dragHandle = (
|
||||
<div
|
||||
onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
|
||||
handlePointerDown(e, index)
|
||||
}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
cursor: isActive ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<GripIcon
|
||||
size="lg"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={{pointerEvents: 'none'} as any}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
key={keyExtractor(item)}
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: index * itemHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: itemHeight,
|
||||
transform: [{translateY: isActive ? translationY : offset}],
|
||||
scale: isActive ? 1.03 : 1,
|
||||
zIndex: isActive ? 999 : 0,
|
||||
boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
|
||||
// Animate scale/shadow on pickup, and transform for
|
||||
// non-dragged items shifting into place.
|
||||
transition: isActive
|
||||
? 'box-shadow 200ms ease, scale 200ms ease'
|
||||
: dragState
|
||||
? 'transform 200ms ease'
|
||||
: 'none',
|
||||
} as any,
|
||||
]}>
|
||||
{renderItem(item, dragHandle)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
|
||||
+23
-24
@@ -1,4 +1,4 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {useCallback, useEffect, useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
@@ -72,11 +71,11 @@ export function Link({
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
const href = useMemo(() => {
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
@@ -211,7 +210,7 @@ export function Description({
|
||||
description,
|
||||
...rest
|
||||
}: {description?: string} & Partial<RichTextProps>) {
|
||||
const rt = React.useMemo(() => {
|
||||
const rt = useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
@@ -278,7 +277,7 @@ function SaveButtonInner({
|
||||
pin?: boolean
|
||||
text?: boolean
|
||||
} & Partial<ButtonProps>) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
useAddSavedFeedsMutation()
|
||||
@@ -288,13 +287,13 @@ function SaveButtonInner({
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
const savedFeedConfig = useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
|
||||
const toggleSave = React.useCallback(
|
||||
const toggleSave = useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -311,17 +310,17 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
Toast.show(l`Failed to update feeds`, 'xmark')
|
||||
}
|
||||
},
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
)
|
||||
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
const onPromptRemoveFeed = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -334,11 +333,13 @@ function SaveButtonInner({
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
label={l`Add this feed to your feeds`}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||
onPress={(e: GestureResponderEvent) =>
|
||||
savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e)
|
||||
}
|
||||
{...buttonProps}>
|
||||
{savedFeedConfig ? (
|
||||
<>
|
||||
@@ -349,7 +350,7 @@ function SaveButtonInner({
|
||||
)}
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Unpin Feed</Trans>
|
||||
<Trans>Unpin feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -358,7 +359,7 @@ function SaveButtonInner({
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Pin Feed</Trans>
|
||||
<Trans>Pin feed</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -367,12 +368,10 @@ function SaveButtonInner({
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from your feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
title={l`Remove from your feeds?`}
|
||||
description={l`Are you sure you want to remove this from your feeds?`}
|
||||
onConfirm={(e: GestureResponderEvent) => void toggleSave(e)}
|
||||
confirmButtonCta={l`Remove`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -8,8 +8,9 @@ import Animated, {
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Fragment, useMemo} from 'react'
|
||||
import {Text as RNText} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
moderateProfile,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
@@ -205,9 +206,8 @@ function KnownFollowersInner({
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans>
|
||||
</Trans> // only 2
|
||||
) : (
|
||||
// only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
import type React from 'react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {type AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
moderateUserList,
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {memo} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -272,7 +273,8 @@ export function ContainerItem({
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -310,7 +312,8 @@ export function Group({children, style}: GroupProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.rounded_lg,
|
||||
a.curve_continuous,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DropdownMenu} from 'radix-ui'
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
@@ -29,7 +30,7 @@ export function NewskieDialog({
|
||||
const {_} = useLingui()
|
||||
const control = useDialogControl()
|
||||
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
const createdAt = profile.createdAt
|
||||
|
||||
const [now] = useState(() => Date.now())
|
||||
const daysOld = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {BSKY_LABELER_DID, type ModerationCause} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
|
||||
@@ -94,7 +94,6 @@ export function ExternalGif({
|
||||
source={params.source}
|
||||
onAccept={load}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
{height: 300},
|
||||
|
||||
@@ -17,7 +17,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {WebView} from 'react-native-webview'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, {useCallback} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
@@ -2,7 +2,7 @@ import {useImperativeHandle, useRef, useState} from 'react'
|
||||
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useId, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type * as HlsTypes from 'hls.js'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
@@ -401,8 +402,8 @@ export function Controls({
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeLabel={_(msg`Disable captions`)}
|
||||
inactiveLabel={_(msg`Enable captions`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
@@ -2,8 +2,9 @@ import {useCallback, useRef, useState} from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, platform} from '#/alf'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
moderatePost,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserInfoText} from '#/view/com/util/UserInfoText'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {LayoutAnimation, type TextStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf'
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {Platform, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {guessLanguage, useTranslate} from '#/lib/translation'
|
||||
import {type TranslationFunction} from '#/lib/translation'
|
||||
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
||||
import {LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {createStaticClick, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Select from '#/components/Select'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function TranslatedPost({
|
||||
hideTranslateLink = false,
|
||||
post,
|
||||
postText,
|
||||
}: {
|
||||
hideTranslateLink?: boolean
|
||||
post: AppBskyFeedDefs.PostView
|
||||
postText: string
|
||||
}) {
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {clearTranslation, translate, translationState} = useTranslate({
|
||||
key: post.uri,
|
||||
})
|
||||
|
||||
const postLanguage = useMemo(() => guessLanguage(postText), [postText])
|
||||
const needsTranslation = postLanguage !== langPrefs.primaryLanguage
|
||||
|
||||
switch (translationState.status) {
|
||||
case 'loading':
|
||||
return <TranslationLoading />
|
||||
case 'success':
|
||||
return (
|
||||
<TranslationResult
|
||||
clearTranslation={clearTranslation}
|
||||
translate={translate}
|
||||
postText={postText}
|
||||
sourceLanguage={
|
||||
translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS
|
||||
}
|
||||
translatedText={translationState.translatedText}
|
||||
/>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<TranslationError
|
||||
clearTranslation={clearTranslation}
|
||||
message={translationState.message}
|
||||
postText={postText}
|
||||
primaryLanguage={langPrefs.primaryLanguage}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
!hideTranslateLink &&
|
||||
needsTranslation && (
|
||||
<TranslationLink
|
||||
postText={postText}
|
||||
primaryLanguage={langPrefs.primaryLanguage}
|
||||
sourceLanguage={postLanguage}
|
||||
translate={translate}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function TranslationLoading() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Loader size="xs" />
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Translating…</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TranslationLink({
|
||||
postText,
|
||||
primaryLanguage,
|
||||
sourceLanguage,
|
||||
translate,
|
||||
}: {
|
||||
postText: string
|
||||
primaryLanguage: string
|
||||
sourceLanguage: string | null
|
||||
translate: TranslationFunction
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
|
||||
const handleTranslate = useCallback(() => {
|
||||
void translate({
|
||||
text: postText,
|
||||
targetLangCode: primaryLanguage,
|
||||
})
|
||||
|
||||
ax.metric('translate', {
|
||||
sourceLanguages: sourceLanguage ? [sourceLanguage] : [],
|
||||
targetLanguage: primaryLanguage,
|
||||
textLength: postText.length,
|
||||
})
|
||||
}, [ax, postText, primaryLanguage, translate, sourceLanguage])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.gap_md,
|
||||
a.pt_md,
|
||||
a.align_start,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
]}>
|
||||
<Link
|
||||
role={IS_WEB ? 'link' : 'button'}
|
||||
{...createStaticClick(() => {
|
||||
handleTranslate()
|
||||
})}
|
||||
label={l`Translate`}
|
||||
hoverStyle={[
|
||||
native({opacity: 0.5}),
|
||||
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||
]}
|
||||
hitSlop={HITSLOP_30}>
|
||||
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
|
||||
<Trans>Translate</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TranslationError({
|
||||
clearTranslation,
|
||||
message,
|
||||
postText,
|
||||
primaryLanguage,
|
||||
}: {
|
||||
clearTranslation: () => void
|
||||
message: string
|
||||
postText: string
|
||||
primaryLanguage: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
const handleFallback = () => {
|
||||
void translate(postText, primaryLanguage)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.mt_sm,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}>
|
||||
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={{opacity: 0.5}}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Link
|
||||
{...createStaticClick(() => {
|
||||
handleFallback()
|
||||
})}
|
||||
label={l`Try Google Translate`}
|
||||
hoverStyle={[
|
||||
native({opacity: 0.5}),
|
||||
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||
]}
|
||||
hitSlop={HITSLOP_30}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}>
|
||||
<Trans>Try Google Translate</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TranslationResult({
|
||||
clearTranslation,
|
||||
translate,
|
||||
postText,
|
||||
sourceLanguage,
|
||||
translatedText,
|
||||
}: {
|
||||
clearTranslation: () => void
|
||||
translate: TranslationFunction
|
||||
postText: string
|
||||
sourceLanguage: string | null
|
||||
translatedText: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {i18n, t: l} = useLingui()
|
||||
|
||||
const langName = sourceLanguage
|
||||
? codeToLanguageName(sourceLanguage, i18n.locale)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.mt_sm,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||
{langName ? (
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{langName}{' '}
|
||||
</Text>
|
||||
<View style={[a.mt_2xs]}>
|
||||
<ArrowRightIcon
|
||||
size="xs"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{' '}
|
||||
{codeToLanguageName(
|
||||
langPrefs.primaryLanguage,
|
||||
langPrefs.appLanguage,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_xs,
|
||||
]}>
|
||||
<Trans>Translated</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{sourceLanguage != null && (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
</Text>
|
||||
<TranslationLanguageSelect
|
||||
sourceLanguage={sourceLanguage}
|
||||
translate={translate}
|
||||
postText={postText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
||||
{translatedText}
|
||||
</Text>
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}
|
||||
style={[a.absolute, a.z_10, {top: 12, right: 14}]}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TranslationLanguageSelect({
|
||||
translate,
|
||||
postText,
|
||||
sourceLanguage,
|
||||
}: {
|
||||
translate: TranslationFunction
|
||||
postText: string
|
||||
sourceLanguage: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
LANGUAGES.filter(
|
||||
(lang, index, self) =>
|
||||
!langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
|
||||
index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Prioritize sourceLanguage at the top
|
||||
if (a.code2 === sourceLanguage) return -1
|
||||
if (b.code2 === sourceLanguage) return 1
|
||||
// Localized sort
|
||||
return languageName(a, langPrefs.appLanguage).localeCompare(
|
||||
languageName(b, langPrefs.appLanguage),
|
||||
langPrefs.appLanguage,
|
||||
)
|
||||
})
|
||||
.map(l => ({
|
||||
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
|
||||
value: l.code2,
|
||||
})),
|
||||
[langPrefs, sourceLanguage],
|
||||
)
|
||||
|
||||
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
|
||||
ax.metric('translate:override', {
|
||||
os: Platform.OS,
|
||||
sourceLanguage: sourceLangCode,
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
})
|
||||
void translate({
|
||||
text: postText,
|
||||
targetLangCode: langPrefs.primaryLanguage,
|
||||
sourceLangCode,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={sourceLanguage}
|
||||
onValueChange={handleChangeTranslationLanguage}>
|
||||
<Select.Trigger label={l`Change the source language`}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<Button
|
||||
label={props.accessibilityLabel}
|
||||
{...props}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
<Trans>Change</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
label={l`Select the source language`}
|
||||
renderItem={({label, value}) => (
|
||||
<Select.Item value={value} label={label}>
|
||||
<Select.ItemIndicator />
|
||||
<Select.ItemText>{label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import {memo} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import type React from 'react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {t} from '@lingui/macro'
|
||||
import {t} from '@lingui/core/macro'
|
||||
|
||||
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
@@ -13,13 +13,12 @@ import {
|
||||
AtUri,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {useTranslate} from '#/lib/hooks/useTranslate'
|
||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {useTranslate} from '#/lib/translation'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -106,6 +106,7 @@ let PostMenuItems = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
logContext,
|
||||
forceGoogleTranslate,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -120,9 +121,10 @@ let PostMenuItems = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
forceGoogleTranslate: boolean
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
|
||||
@@ -133,7 +135,10 @@ let PostMenuItems = ({
|
||||
const {hidePost} = useHiddenPostsApi()
|
||||
const feedFeedback = useFeedFeedbackContext()
|
||||
const openLink = useOpenLink()
|
||||
const translate = useTranslate()
|
||||
const {clearTranslation, translate, translationState} = useTranslate({
|
||||
key: post.uri,
|
||||
forceGoogleTranslate,
|
||||
})
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const blockPromptControl = useDialogControl()
|
||||
@@ -191,7 +196,7 @@ let PostMenuItems = ({
|
||||
const onDeletePost = () => {
|
||||
deletePostMutate({uri: postUri}).then(
|
||||
() => {
|
||||
Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
|
||||
Toast.show(l({message: 'Post deleted', context: 'toast'}))
|
||||
|
||||
const route = getCurrentRoute(navigation.getState())
|
||||
if (route.name === 'PostThread') {
|
||||
@@ -211,7 +216,7 @@ let PostMenuItems = ({
|
||||
},
|
||||
e => {
|
||||
logger.error('Failed to delete post', {message: e})
|
||||
Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
|
||||
Toast.show(l`Failed to delete post, please try again`, 'xmark')
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -219,33 +224,29 @@ let PostMenuItems = ({
|
||||
const onToggleThreadMute = () => {
|
||||
try {
|
||||
if (isThreadMuted) {
|
||||
unmuteThread()
|
||||
void unmuteThread()
|
||||
ax.metric('post:unmute', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(_(msg`You will now receive notifications for this thread`))
|
||||
Toast.show(l`You will now receive notifications for this thread`)
|
||||
} else {
|
||||
muteThread()
|
||||
void muteThread()
|
||||
ax.metric('post:mute', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`You will no longer receive notifications for this thread`),
|
||||
)
|
||||
Toast.show(l`You will no longer receive notifications for this thread`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to toggle thread mute', {message: e})
|
||||
Toast.show(
|
||||
_(msg`Failed to toggle thread mute, please try again`),
|
||||
'xmark',
|
||||
)
|
||||
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,12 +254,15 @@ let PostMenuItems = ({
|
||||
const onCopyPostText = () => {
|
||||
const str = richTextToString(richText, true)
|
||||
|
||||
Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||
void Clipboard.setStringAsync(str)
|
||||
Toast.show(l`Copied to clipboard`, 'clipboard-check')
|
||||
}
|
||||
|
||||
const onPressTranslate = () => {
|
||||
translate(record.text, langPrefs.primaryLanguage)
|
||||
void translate({
|
||||
text: record.text,
|
||||
targetLangCode: langPrefs.primaryLanguage,
|
||||
})
|
||||
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
@@ -296,9 +300,7 @@ let PostMenuItems = ({
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
)
|
||||
Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
|
||||
}
|
||||
|
||||
const onPressShowLess = () => {
|
||||
@@ -321,7 +323,7 @@ let PostMenuItems = ({
|
||||
})
|
||||
} else {
|
||||
Toast.show(
|
||||
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||
l({message: 'Feedback sent to feed operator', context: 'toast'}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -340,12 +342,13 @@ let PostMenuItems = ({
|
||||
})
|
||||
Toast.show(
|
||||
isDetach
|
||||
? _(msg`Quote post was successfully detached`)
|
||||
: _(msg`Quote post was re-attached`),
|
||||
? l`Quote post was successfully detached`
|
||||
: l`Quote post was re-attached`,
|
||||
)
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
Toast.show(
|
||||
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
|
||||
l({message: 'Updating quote attachment failed', context: 'toast'}),
|
||||
)
|
||||
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
|
||||
}
|
||||
@@ -377,30 +380,27 @@ let PostMenuItems = ({
|
||||
|
||||
Toast.show(
|
||||
isHide
|
||||
? _(msg`Reply was successfully hidden`)
|
||||
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
||||
? l`Reply was successfully hidden`
|
||||
: l({message: 'Reply visibility updated', context: 'toast'}),
|
||||
)
|
||||
} catch (e: any) {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e instanceof MaxHiddenRepliesError) {
|
||||
Toast.show(
|
||||
_(
|
||||
plural(MAX_HIDDEN_REPLIES, {
|
||||
other: 'You can hide a maximum of # replies.',
|
||||
}),
|
||||
),
|
||||
plural(MAX_HIDDEN_REPLIES, {
|
||||
other: 'You can hide a maximum of # replies.',
|
||||
}),
|
||||
)
|
||||
} else if (e instanceof InvalidInteractionSettingsError) {
|
||||
Toast.show(
|
||||
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
|
||||
l({message: 'Invalid interaction settings.', context: 'toast'}),
|
||||
)
|
||||
} else {
|
||||
Toast.show(
|
||||
_(
|
||||
msg({
|
||||
message: 'Updating reply visibility failed',
|
||||
context: 'toast',
|
||||
}),
|
||||
),
|
||||
l({
|
||||
message: 'Updating reply visibility failed',
|
||||
context: 'toast',
|
||||
}),
|
||||
)
|
||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||
}
|
||||
@@ -409,7 +409,7 @@ let PostMenuItems = ({
|
||||
|
||||
const onPressPin = () => {
|
||||
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
|
||||
pinPostMutate({
|
||||
void pinPostMutate({
|
||||
postUri,
|
||||
postCid,
|
||||
action: isPinned ? 'unpin' : 'pin',
|
||||
@@ -419,11 +419,12 @@ let PostMenuItems = ({
|
||||
const onBlockAuthor = async () => {
|
||||
try {
|
||||
await queueBlock()
|
||||
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to block account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,21 +433,23 @@ let PostMenuItems = ({
|
||||
if (postAuthor.viewer?.muted) {
|
||||
try {
|
||||
await queueUnmute()
|
||||
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unmute account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await queueMute()
|
||||
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account muted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to mute account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
|
||||
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,11 +459,13 @@ let PostMenuItems = ({
|
||||
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
||||
href,
|
||||
)}`
|
||||
openLink(url)
|
||||
void openLink(url)
|
||||
}
|
||||
|
||||
const onSignIn = () => requireSignIn(() => {})
|
||||
|
||||
const onPressHideTranslation = () => clearTranslation()
|
||||
|
||||
const isDiscoverDebugUser =
|
||||
IS_INTERNAL ||
|
||||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
|
||||
@@ -475,16 +480,12 @@ let PostMenuItems = ({
|
||||
<Menu.Item
|
||||
testID="pinPostBtn"
|
||||
label={
|
||||
isPinned
|
||||
? _(msg`Unpin from profile`)
|
||||
: _(msg`Pin to your profile`)
|
||||
isPinned ? l`Unpin from profile` : l`Pin to your profile`
|
||||
}
|
||||
disabled={isPinPending}
|
||||
onPress={onPressPin}>
|
||||
<Menu.ItemText>
|
||||
{isPinned
|
||||
? _(msg`Unpin from profile`)
|
||||
: _(msg`Pin to your profile`)}
|
||||
{isPinned ? l`Unpin from profile` : l`Pin to your profile`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isPinPending ? Loader : PinIcon}
|
||||
@@ -499,28 +500,46 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
{!hideInPWI || hasSession ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslate}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
{translationState.status === 'loading' ? (
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={l`Translating…`}
|
||||
onPress={() => {}}>
|
||||
<Menu.ItemText>{l`Translating…`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
) : translationState.status === 'success' ? (
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={l`Hide translation`}
|
||||
onPress={onPressHideTranslation}>
|
||||
<Menu.ItemText>{l`Hide translation`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
testID="postDropdownTranslateBtn"
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslate}>
|
||||
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownCopyTextBtn"
|
||||
label={_(msg`Copy post text`)}
|
||||
label={l`Copy post text`}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Copy post text`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
testID="postDropdownSignInBtn"
|
||||
label={_(msg`Sign in to view post`)}
|
||||
label={l`Sign in to view post`}
|
||||
onPress={onSignIn}>
|
||||
<Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Eye} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -532,17 +551,17 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postDropdownShowMoreBtn"
|
||||
label={_(msg`Show more like this`)}
|
||||
label={l`Show more like this`}
|
||||
onPress={onPressShowMore}>
|
||||
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Show more like this`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={EmojiSmile} position="right" />
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownShowLessBtn"
|
||||
label={_(msg`Show less like this`)}
|
||||
label={l`Show less like this`}
|
||||
onPress={onPressShowLess}>
|
||||
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Show less like this`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
@@ -554,9 +573,9 @@ let PostMenuItems = ({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
testID="postDropdownReportMisclassificationBtn"
|
||||
label={_(msg`Assign topic for algo`)}
|
||||
label={l`Assign topic for algo`}
|
||||
onPress={onReportMisclassification}>
|
||||
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -568,12 +587,10 @@ let PostMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postDropdownMuteThreadBtn"
|
||||
label={
|
||||
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
|
||||
}
|
||||
label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||
onPress={onToggleThreadMute}>
|
||||
<Menu.ItemText>
|
||||
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
|
||||
{isThreadMuted ? l`Unmute thread` : l`Mute thread`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isThreadMuted ? Unmute : Mute}
|
||||
@@ -583,9 +600,9 @@ let PostMenuItems = ({
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownMuteWordsBtn"
|
||||
label={_(msg`Mute words & tags`)}
|
||||
label={l`Mute words & tags`}
|
||||
onPress={() => mutedWordsDialogControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Filter} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
@@ -600,16 +617,10 @@ let PostMenuItems = ({
|
||||
{canHidePostForMe && (
|
||||
<Menu.Item
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
isReply
|
||||
? _(msg`Hide reply for me`)
|
||||
: _(msg`Hide post for me`)
|
||||
}
|
||||
label={isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||
onPress={() => hidePromptControl.open()}>
|
||||
<Menu.ItemText>
|
||||
{isReply
|
||||
? _(msg`Hide reply for me`)
|
||||
: _(msg`Hide post for me`)}
|
||||
{isReply ? l`Hide reply for me` : l`Hide post for me`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={EyeSlash} position="right" />
|
||||
</Menu.Item>
|
||||
@@ -619,8 +630,8 @@ let PostMenuItems = ({
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)
|
||||
? l`Show reply for everyone`
|
||||
: l`Hide reply for everyone`
|
||||
}
|
||||
onPress={
|
||||
isReplyHiddenByThreadgate
|
||||
@@ -629,8 +640,8 @@ let PostMenuItems = ({
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
{isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)}
|
||||
? l`Show reply for everyone`
|
||||
: l`Hide reply for everyone`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
|
||||
@@ -645,8 +656,8 @@ let PostMenuItems = ({
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)
|
||||
? l`Re-attach quote`
|
||||
: l`Detach quote`
|
||||
}
|
||||
onPress={
|
||||
quoteEmbed.isDetached
|
||||
@@ -655,8 +666,8 @@ let PostMenuItems = ({
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
{quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)}
|
||||
? l`Re-attach quote`
|
||||
: l`Detach quote`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={
|
||||
@@ -684,14 +695,14 @@ let PostMenuItems = ({
|
||||
testID="postDropdownMuteBtn"
|
||||
label={
|
||||
postAuthor.viewer?.muted
|
||||
? _(msg`Unmute account`)
|
||||
: _(msg`Mute account`)
|
||||
? l`Unmute account`
|
||||
: l`Mute account`
|
||||
}
|
||||
onPress={onMuteAuthor}>
|
||||
onPress={() => void onMuteAuthor()}>
|
||||
<Menu.ItemText>
|
||||
{postAuthor.viewer?.muted
|
||||
? _(msg`Unmute account`)
|
||||
: _(msg`Mute account`)}
|
||||
? l`Unmute account`
|
||||
: l`Mute account`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
|
||||
@@ -702,18 +713,18 @@ let PostMenuItems = ({
|
||||
{!postAuthor.viewer?.blocking && (
|
||||
<Menu.Item
|
||||
testID="postDropdownBlockBtn"
|
||||
label={_(msg`Block account`)}
|
||||
label={l`Block account`}
|
||||
onPress={() => blockPromptControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Block account`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={PersonX} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownReportBtn"
|
||||
label={_(msg`Report post`)}
|
||||
label={l`Report post`}
|
||||
onPress={() => reportDialogControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Report post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Warning} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -723,7 +734,7 @@ let PostMenuItems = ({
|
||||
<>
|
||||
<Menu.Item
|
||||
testID="postDropdownEditPostInteractions"
|
||||
label={_(msg`Edit interaction settings`)}
|
||||
label={l`Edit interaction settings`}
|
||||
onPress={() => postInteractionSettingsDialogControl.open()}
|
||||
{...(isAuthor
|
||||
? Platform.select({
|
||||
@@ -736,15 +747,15 @@ let PostMenuItems = ({
|
||||
})
|
||||
: {})}>
|
||||
<Menu.ItemText>
|
||||
{_(msg`Edit interaction settings`)}
|
||||
{l`Edit interaction settings`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Gear} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="postDropdownDeleteBtn"
|
||||
label={_(msg`Delete post`)}
|
||||
label={l`Delete post`}
|
||||
onPress={() => deletePromptControl.open()}>
|
||||
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Delete post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} position="right" />
|
||||
</Menu.Item>
|
||||
</>
|
||||
@@ -753,28 +764,21 @@ let PostMenuItems = ({
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deletePromptControl}
|
||||
title={_(msg`Delete this post?`)}
|
||||
description={_(
|
||||
msg`If you remove this post, you won't be able to recover it.`,
|
||||
)}
|
||||
title={l`Delete this post?`}
|
||||
description={l`If you remove this post, you won't be able to recover it.`}
|
||||
onConfirm={onDeletePost}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={hidePromptControl}
|
||||
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
|
||||
description={_(
|
||||
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
|
||||
)}
|
||||
title={isReply ? l`Hide this reply?` : l`Hide this post?`}
|
||||
description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
|
||||
onConfirm={onHidePost}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
confirmButtonCta={l`Hide`}
|
||||
/>
|
||||
|
||||
<ReportDialog
|
||||
control={reportDialogControl}
|
||||
subject={{
|
||||
@@ -782,42 +786,32 @@ let PostMenuItems = ({
|
||||
$type: 'app.bsky.feed.defs#postView',
|
||||
}}
|
||||
/>
|
||||
|
||||
<PostInteractionSettingsDialog
|
||||
control={postInteractionSettingsDialogControl}
|
||||
postUri={post.uri}
|
||||
rootPostUri={rootUri}
|
||||
initialThreadgateView={post.threadgate}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={quotePostDetachConfirmControl}
|
||||
title={_(msg`Detach quote post?`)}
|
||||
description={_(
|
||||
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
|
||||
)}
|
||||
onConfirm={onToggleQuotePostAttachment}
|
||||
confirmButtonCta={_(msg`Yes, detach`)}
|
||||
title={l`Detach quote post?`}
|
||||
description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
|
||||
onConfirm={() => void onToggleQuotePostAttachment()}
|
||||
confirmButtonCta={l`Yes, detach`}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={hideReplyConfirmControl}
|
||||
title={_(msg`Hide this reply?`)}
|
||||
description={_(
|
||||
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
|
||||
)}
|
||||
onConfirm={onToggleReplyVisibility}
|
||||
confirmButtonCta={_(msg`Yes, hide`)}
|
||||
title={l`Hide this reply?`}
|
||||
description={l`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`}
|
||||
onConfirm={() => void onToggleReplyVisibility()}
|
||||
confirmButtonCta={l`Yes, hide`}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={blockPromptControl}
|
||||
title={_(msg`Block Account?`)}
|
||||
description={_(
|
||||
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
|
||||
)}
|
||||
onConfirm={onBlockAuthor}
|
||||
confirmButtonCta={_(msg`Block`)}
|
||||
title={l`Block Account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={() => void onBlockAuthor()}
|
||||
confirmButtonCta={l`Block`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user