Compare commits

...

50 Commits

Author SHA1 Message Date
Samuel Newman d8aa9ff190 move translate stuff to a features directory 2026-03-03 15:49:47 +02:00
Samuel Newman f1ee4d2c7c make hover style consistent 2026-03-03 11:32:27 +02:00
Samuel Newman 6a6cb46581 Fix translate hitslop by using Buttons 2026-03-03 11:31:31 +02:00
pfrazee c42502fdcb Nightly source-language update 2026-03-03 03:09:48 +00:00
Samuel Newman 8b1c47a499 bskyweb: use avatar thumbnail for post og:image fallback (#9923) 2026-03-02 22:28:16 +00:00
Samuel Newman c2fd87bd8b Remove JPG hardcoding from image processing systems (#9955) 2026-03-02 20:23:07 +00:00
Samuel Newman d247a65683 Add Trans to string (#9981) 2026-03-02 11:39:50 -08:00
Spence Pope a95b877ea3 [APP-1917] fix header measurement for android content disappearing on switch (#9964)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-03-02 14:20:16 -05:00
Samuel Newman f614bf650d Translation followups (#9980) 2026-03-02 11:01:09 -08:00
Samuel Newman 5ee667f307 Force relayout to fix stuck header blur (#9979) 2026-03-02 10:50:00 -08:00
Spence Pope 9c29b2867a [APP-1882] fix email not updating in session state after email change (#9953) 2026-03-02 06:09:21 -08:00
smileyhead 9746dd8e4c Make ALT labels translatable (#9976) 2026-03-02 13:14:07 +00:00
DS Boyce 8e2a5abfff Fix crash with translate link on web (#9972) 2026-02-28 23:53:42 +00:00
pfrazee 6cdc1fe2b5 Nightly source-language update 2026-02-28 02:56:49 +00:00
DS Boyce 9bbcb472ef Use on-device translation on mobile when available (#9930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 16:59:19 -08:00
Samuel Newman 7c9f05a2af ContextMenu - return item to the right location if keyboard hides (#9963) 2026-02-27 22:19:04 +00:00
Samuel Newman d4357b2cb8 Fix iOS sheet keyboard handling using native scrollview features (#9959) 2026-02-27 13:59:24 -08:00
Spence Pope 576761a645 [APP-1819] fix progress guide flash and extra gap (#9960) 2026-02-27 08:31:35 -08:00
DS Boyce 8a1f8997fe Fix infinite loading spinner when changing search terms (#9950)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-27 03:42:31 -08:00
pfrazee 6cb88ce1f3 Nightly source-language update 2026-02-27 03:06:31 +00:00
Samuel Newman 65c4b7833d Blur effect on home screen (iOS 26) (#9935) 2026-02-26 07:00:12 -08:00
Spence Pope 9b24d75c9c [APP-1876] Fix pull-to-refresh getting stuck on tab switch (#9951) 2026-02-26 02:06:30 -08:00
Spence Pope 398df1a4b5 ensure app session state refreshes on reactivation (#9954)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:06:16 -08:00
pfrazee db9c2a1596 Nightly source-language update 2026-02-26 03:07:29 +00:00
Alex Benzer 00816b70dc [APP-1859] pinned feed drag n drop (#9893)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: vineyardbovines <spencerfpope@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:52:10 -08:00
Samuel Newman 49272be36b Fix up polyfills, remove unused deps (#9946) 2026-02-25 11:43:46 -08:00
surfdude29 85ffc77983 Show hashtag symbol for Mute option in menu (#9942) 2026-02-25 01:18:20 -08:00
pfrazee 95bede2dcb Nightly source-language update 2026-02-25 03:09:49 +00:00
surfdude29 e656fe688d Align strings from 'subtitles' to 'captions' (#9941) 2026-02-24 21:56:12 +00:00
Samuel Newman db1f45de92 iOS 26 tweaks #2 (#9938) 2026-02-24 12:14:19 -08:00
Samuel Newman bc61314c41 iOS 26 followups (#9936) 2026-02-24 08:12:57 -08:00
mozzius 1850042c37 Nightly source-language update 2026-02-24 07:17:35 +00:00
Samuel Newman 782edbf281 Restore type assert that got accidentally removed (#9934) 2026-02-23 23:11:39 -08:00
Samuel Newman 73b096e443 iOS 26 (#9047)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:34:52 -08:00
pfrazee 9b0f0a8d24 Nightly source-language update 2026-02-24 03:09:35 +00:00
Samuel Newman 9ec06971af Upgrade Lingui to v5 (#9905) 2026-02-23 20:28:03 +00:00
Samuel Newman 75242b9b1f Bump version to v1.118.0 (#9931) 2026-02-23 12:04:58 -08:00
pfrazee 21e53b226d Nightly source-language update 2026-02-22 03:10:59 +00:00
surfdude29 02c2a9ea7c Mark admonition text for translation in CreateListFromStarterPackDialog (#9922) 2026-02-21 16:53:44 +00:00
Eric Bailey cb7e2ab976 Release prep 1.117.0 (#9920) 2026-02-20 14:30:14 -06:00
Eric Bailey 067c6dacc9 Update location copy (#9918) 2026-02-20 13:20:04 -06:00
Eric Bailey 9b8c46cdb7 Add A/A test (#9917) 2026-02-20 13:19:56 -06:00
Samuel Newman f3cc850b62 Fix up typescript config (#9910) 2026-02-20 18:07:17 +00:00
Samuel Newman 35f9ed82b9 Fix string concat bug with trending topics muteword moderation (#9914) 2026-02-20 11:29:08 -06:00
Samuel Newman 8c4fc087f8 Add ability to convert starter pack to list (#9675)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@users.noreply.github.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-20 11:27:55 -06:00
Samuel Newman f3ddc074a6 Fork MCEmojiPicker (#9763) 2026-02-20 10:39:48 -06:00
Spence Pope 5e0ffc0917 [APP-1809] add cleanup button for unavailable moderation services (#9901)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-20 10:23:01 -06:00
Samuel Newman 3fae42c077 Remove hardcoded warning color, add yellow and pink to ALF palette (#9911) 2026-02-20 10:17:11 -06:00
Sylwia 80c0aa35e2 Add CORS headers to /oembed API (#9845) 2026-02-19 22:07:49 -08:00
Eric Bailey 4e3c3e9905 [APP-1805] Analytics tweaks and tracking callbacks (#9861) 2026-02-19 15:31:35 -06:00
607 changed files with 63974 additions and 50030 deletions
+3 -1
View File
@@ -110,7 +110,9 @@ google-services.json
# i18n
src/locale/locales/_build/
src/locale/locales/**/*.js
src/locale/locales/**/messages.js
src/locale/locales/**/messages.mjs
src/locale/locales/**/messages.ts
# local builds
*.apk
+2 -1
View File
@@ -271,7 +271,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() {
-6
View File
@@ -1,6 +0,0 @@
export default {
requestPermission: jest.fn(),
onForegroundEvent: jest.fn(),
setBadgeCount: jest.fn(),
displayNotification: jest.fn(),
}
@@ -1,9 +0,0 @@
export const CameraRoll = {
getPhotos: jest.fn().mockResolvedValue({
edges: [
{node: {image: {uri: 'path/to/image1.jpg'}}},
{node: {image: {uri: 'path/to/image2.jpg'}}},
{node: {image: {uri: 'path/to/image3.jpg'}}},
],
}),
}
@@ -1,4 +0,0 @@
export default {
configure: jest.fn().mockResolvedValue(0),
finish: jest.fn(),
}
-1
View File
@@ -1 +0,0 @@
export default {}
-10
View File
@@ -1,10 +0,0 @@
jest.mock('rn-fetch-blob', () => {
return {
__esModule: true,
default: {
fs: {
unlink: jest.fn(),
},
},
}
})
-2
View File
@@ -1,2 +0,0 @@
export const DropdownMenu = jest.fn().mockImplementation(() => {})
export const create = jest.fn().mockImplementation(() => {})
+6
View File
@@ -1,4 +1,5 @@
import {RichText} from '@atproto/api'
import {i18n} from '@lingui/core'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {
@@ -6,6 +7,7 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
@@ -202,6 +204,9 @@ describe('enforceLen', () => {
})
describe('cleanError', () => {
// cleanError uses lingui
i18n.loadAndActivate({locale: 'en', messages})
const inputs = [
'TypeError: Network request failed',
'Error: Aborted',
@@ -327,6 +332,7 @@ describe('shortenLinks', () => {
expect(outputRT.text).toEqual(outputs[i][0])
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
for (let j = 0; j < outputs[i][1].length; j++) {
// @ts-expect-error whatever
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
}
}
+7 -1
View File
@@ -112,7 +112,6 @@ module.exports = function (_config) {
'zh-Hans',
'zh-Hant',
],
UIDesignRequiresCompatibility: true,
},
associatedDomains: ASSOCIATED_DOMAINS,
entitlements: {
@@ -256,6 +255,13 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 35,
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = function (api) {
],
],
plugins: [
'macros',
'@lingui/babel-plugin-lingui-macro',
['babel-plugin-react-compiler', {target: '19'}],
[
'module:react-native-dotenv',
+12 -1
View File
@@ -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')}`}
/>
)
}
+7
View File
@@ -2,12 +2,14 @@ package main
import (
"net/url"
"strings"
"github.com/flosch/pongo2/v6"
)
func init() {
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
}
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
@@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
// Return the cleaned URL
return pongo2.AsValue(parsedURL.String()), nil
}
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
urlStr := in.String()
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
}
+8 -1
View File
@@ -178,6 +178,13 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// Create CORS middleware for oembed
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
})
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
@@ -205,7 +212,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed)
e.GET("/oembed", server.WebOEmbed, oembedCORS)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
+2 -2
View File
@@ -35,8 +35,8 @@
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
+1 -1
View File
@@ -164,8 +164,8 @@ See [testing.md](./testing.md).
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
- TextEncoder / TextDecoder
- react-native-url-polyfill
- Array#findLast (on web)
- setImmediate (on web)
### Sentry sourcemaps
+21 -6
View File
@@ -73,7 +73,7 @@ import { Text } from "react-native";
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/macro";
import { Trans } from "@lingui/react/macro";
<Text><Trans>Hello World</Trans></Text>
```
@@ -90,18 +90,33 @@ const text = "Hello World";
```
In this case, you can use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/macro";
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
```jsx
import { useLingui } from "@lingui/react/macro";
const { t } = useLingui();
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
```
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/core/macro";
// not ideal - t only gets called once at module evaluation time
const text = t`Hello World`;
// however, this is suitable for strings that are ephemeral:
function sayHello() {
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
}
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
@@ -121,7 +136,7 @@ So the workflow is as follows:
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/macro";
import { msg } from "@lingui/core/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
-2
View File
@@ -23,8 +23,6 @@ export default defineConfig(
{
ignores: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party/**',
'ios/**',
'android/**',
'coverage/**',
+2 -2
View File
@@ -1,7 +1,5 @@
/* global jest */
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
import {configure} from '@testing-library/react-native'
@@ -36,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => {
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
moveAsync: jest.fn().mockResolvedValue(undefined),
createDownloadResumable: jest.fn(),
}))
@@ -45,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
+6 -4
View File
@@ -1,5 +1,7 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
import {defineConfig} from '@lingui/cli'
export default defineConfig({
sourceLocale: 'en',
locales: [
'en',
'an',
@@ -49,5 +51,5 @@ module.exports = {
include: ['src'],
},
],
format: 'po',
}
compileNamespace: 'ts',
})
@@ -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 {
@@ -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
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker', '1.2.3'
s.dependency 'MCEmojiPicker'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
+20 -16
View File
@@ -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",
@@ -76,8 +83,9 @@
"@atproto/api": "^0.18.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -93,10 +101,12 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook-react": "^1.6.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
@@ -123,9 +133,7 @@
"@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",
@@ -163,14 +171,12 @@
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
@@ -201,7 +207,6 @@
"react-native-drawer-layout": "^4.2.1",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "^1.20.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -211,7 +216,6 @@
"react-native-screens": "^4.19.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "^0.21.0",
@@ -220,6 +224,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",
@@ -235,8 +240,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",
@@ -251,7 +256,6 @@
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"babel-jest": "^29.7.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-preset-expo": "~54.0.0",
@@ -281,8 +285,8 @@
"svgo": "^3.3.2",
"ts-node": "^10.9.1",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.53.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
-10
View File
@@ -1,10 +0,0 @@
diff --git a/node_modules/@lingui/core/dist/index.mjs b/node_modules/@lingui/core/dist/index.mjs
index 9759736..881f67b 100644
--- a/node_modules/@lingui/core/dist/index.mjs
+++ b/node_modules/@lingui/core/dist/index.mjs
@@ -1,4 +1,4 @@
-import unraw from 'unraw';
+import { unraw } from 'unraw';
import { compileMessage } from '@lingui/message-utils/compileMessage';
const isString = (s) => typeof s === "string";
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -4,7 +4,7 @@ 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'
+26 -4
View File
@@ -2,7 +2,7 @@ import {type JSX, useCallback, useRef} from 'react'
import * as Linking from 'expo-linking'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {
type BottomTabBarProps,
createBottomTabNavigator,
@@ -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,29 @@ function screenOptions(t: Theme) {
function HomeTabNavigator() {
const t = useTheme()
const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS
? ({
headerShown: true,
headerTransparent: true,
headerTitle: '',
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 +1027,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'
+2
View File
@@ -9,4 +9,6 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
AATest = 'aa-test',
}
+10
View File
@@ -18,6 +18,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return device.get(['nativeSessionId'])
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+10
View File
@@ -21,6 +21,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return window.sessionStorage.getItem(SESSION_ID_KEY)
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+20 -15
View File
@@ -1,4 +1,4 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native'
import {Logger} from '#/logger'
@@ -24,7 +24,7 @@ import {
import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env'
import {useGeolocation} from '#/geolocation'
import {useGeolocationServiceResponse} from '#/geolocation/service'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
@@ -104,7 +104,7 @@ const Context = createContext<AnalyticsBaseContextType>({
referrerSrc: refParams.src,
referrerUrl: refParams.url,
},
geolocation: device.get(['mergedGeolocation']) || {
geolocation: device.get(['geolocationServiceResponse']) || {
countryCode: '',
regionCode: '',
},
@@ -137,7 +137,7 @@ export function AnalyticsContext({
}
}
const sessionId = useSessionId()
const geolocation = useGeolocation()
const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context)
const childContext = useMemo(() => {
const combinedMetadata = {
@@ -181,20 +181,25 @@ export function AnalyticsFeaturesContext({
const parentContext = useContext(Context)
/**
* Side-effect: we need to synchronously set this during the
* same render cycle. It does not trigger a re-render, it just
* sets properties on the singleton GrowthBook instance.
* Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance.
*/
setAttributes(parentContext.metadata)
useEffect(() => {
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
}, [parentContext.metric])
})
feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', {
featureId: feature,
featureResultValue: result.value,
experimentId: result.experiment?.key,
variationId: result.experimentResult?.key,
})
})
const childContext = useMemo<AnalyticsContextType>(() => {
return {
+3 -1
View File
@@ -4,6 +4,7 @@ import {Logger} from '#/logger'
import * as env from '#/env'
type Event<M extends Record<string, any>> = {
source: 'app'
time: number
event: keyof M
payload: M[keyof M]
@@ -43,7 +44,8 @@ export class MetricsClient<M extends Record<string, any>> {
) {
this.start()
const e = {
const e: Event<M> = {
source: 'app',
time: Date.now(),
event,
payload,
+25
View File
@@ -2,6 +2,8 @@
* Do not import runtime code into this file
*/
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -15,6 +17,14 @@ export type Events = {
experimentId: string
variationId: string
}
'feature:viewed': {
featureId: string
featureResultValue: unknown
/** Only available if feature has experiment rules applied */
experimentId?: string
/** Only available if feature has experiment rules applied */
variationId?: string
}
'account:loggedIn': {
logContext:
@@ -563,6 +573,10 @@ export type Events = {
profilesCount: number
feedsCount: number
}
'starterPack:convertToList': {
starterPack: string
memberCount: number
}
'starterPack:ctaPress': {
starterPack: string
}
@@ -667,6 +681,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': {}
+2 -1
View File
@@ -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'
+2 -6
View File
@@ -9,10 +9,6 @@ import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons
import {Text as BaseText, type TextProps} from '#/components/Typography'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from './icons/Emoji'
export const colors = {
warning: '#FFC404',
}
type Context = {
type: 'info' | 'tip' | 'warning' | 'error' | 'apology'
}
@@ -35,7 +31,7 @@ export function Icon() {
const fill = {
info: t.atoms.text_contrast_medium.color,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.text_contrast_medium.color,
}[type]
@@ -110,7 +106,7 @@ export function Outer({
const borderColor = {
info: t.atoms.border_contrast_high.borderColor,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.border_contrast_high.borderColor,
}[type]
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -7,7 +7,7 @@ import Animated, {
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
+1 -1
View File
@@ -5,7 +5,7 @@ import Animated, {
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
+7 -7
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {createContext, useContext} from 'react'
import {
type ContextType,
@@ -6,17 +6,17 @@ import {
type MenuContextType,
} from '#/components/ContextMenu/types'
export const Context = React.createContext<ContextType | null>(null)
export const Context = createContext<ContextType | null>(null)
Context.displayName = 'ContextMenuContext'
export const MenuContext = React.createContext<MenuContextType | null>(null)
export const MenuContext = createContext<MenuContextType | null>(null)
MenuContext.displayName = 'ContextMenuMenuContext'
export const ItemContext = React.createContext<ItemContextType | null>(null)
export const ItemContext = createContext<ItemContextType | null>(null)
ItemContext.displayName = 'ContextMenuItemContext'
export function useContextMenuContext() {
const context = React.useContext(Context)
const context = useContext(Context)
if (!context) {
throw new Error(
@@ -28,7 +28,7 @@ export function useContextMenuContext() {
}
export function useContextMenuMenuContext() {
const context = React.useContext(MenuContext)
const context = useContext(MenuContext)
if (!context) {
throw new Error(
@@ -40,7 +40,7 @@ export function useContextMenuMenuContext() {
}
export function useContextMenuItemContext() {
const context = React.useContext(ItemContext)
const context = useContext(ItemContext)
if (!context) {
throw new Error(
+86 -26
View File
@@ -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>
+1
View File
@@ -49,6 +49,7 @@ export type ContextType = {
translationSV: SharedValue<number>
mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
returnLocationSV: SharedValue<{x: number; y: number} | null>
close: () => void
registerHoverable: (
id: string,
+1 -1
View File
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
export const Context = createContext<DialogContextProps>({
close: () => {},
IS_NATIVEDialog: false,
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
+57 -47
View File
@@ -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,
}),
},
+11 -3
View File
@@ -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 -3
View File
@@ -8,6 +8,7 @@ import {
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS} from '#/env'
export function Header({
renderLeft,
@@ -35,7 +36,7 @@ export function Header({
a.flex_row,
a.justify_center,
a.align_center,
{minHeight: 50},
{minHeight: IS_LIQUID_GLASS ? 64 : 50},
a.border_b,
t.atoms.border_contrast_medium,
t.atoms.bg,
@@ -44,11 +45,15 @@ export function Header({
style,
]}>
{renderLeft && (
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
<View style={[a.absolute, {left: IS_LIQUID_GLASS ? 12 : 6}]}>
{renderLeft()}
</View>
)}
{children}
{renderRight && (
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
<View style={[a.absolute, {right: IS_LIQUID_GLASS ? 12 : 6}]}>
{renderRight()}
</View>
)}
</View>
)
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback} from 'react'
import {SystemBars} from 'react-native-edge-to-edge'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_LIQUID_GLASS} from '#/env'
/**
* If we're calling a system API like the image picker that opens a sheet
@@ -9,7 +9,7 @@ import {IS_IOS} from '#/env'
*/
export function useSheetWrapper() {
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
if (IS_IOS) {
if (IS_IOS && !IS_LIQUID_GLASS) {
const entry = SystemBars.pushStackEntry({
style: {
statusBar: 'light',
+1 -1
View File
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
IS_NATIVEDialog: boolean
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
+489
View File
@@ -0,0 +1,489 @@
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
style={[
a.justify_center,
a.align_center,
a.px_sm,
a.py_md,
web({cursor: 'grab'}),
]}
hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={web({pointerEvents: 'none'})}
/>
</Animated.View>
</GestureDetector>
)
return (
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: itemHeight,
},
animatedStyle,
]}>
{renderItem(item, dragHandle)}
</Animated.View>
)
}
+168
View File
@@ -0,0 +1,168 @@
import {useState} from 'react'
import {View} from 'react-native'
import {useTheme} from '#/alf'
import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid'
/**
* Web implementation of SortableList using pointer events.
* See SortableList.tsx for the native version using gesture-handler + Reanimated.
*/
interface SortableListProps<T> {
data: T[]
keyExtractor: (item: T) => string
renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode
onReorder: (data: T[]) => void
onDragStart?: () => void
onDragEnd?: () => void
/** Fixed row height used for position math. */
itemHeight: number
}
export function SortableList<T>({
data,
keyExtractor,
renderItem,
onReorder,
onDragStart,
onDragEnd,
itemHeight,
}: SortableListProps<T>) {
const t = useTheme()
const [dragState, setDragState] = useState<{
activeIndex: number
currentY: number
startY: number
} | null>(null)
const getNewPosition = (state: {
activeIndex: number
currentY: number
startY: number
}) => {
const translationY = state.currentY - state.startY
const rawNewPos = Math.round(
(state.activeIndex * itemHeight + translationY) / itemHeight,
)
return Math.max(0, Math.min(rawNewPos, data.length - 1))
}
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragState) return
e.preventDefault()
setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null))
}
const handlePointerUp = () => {
if (!dragState) return
const newPos = getNewPosition(dragState)
if (newPos !== dragState.activeIndex) {
const next = [...data]
const [moved] = next.splice(dragState.activeIndex, 1)
next.splice(newPos, 0, moved)
onReorder(next)
}
setDragState(null)
onDragEnd?.()
}
const handlePointerDown = (e: React.PointerEvent, index: number) => {
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY})
onDragStart?.()
}
const newPos = dragState ? getNewPosition(dragState) : -1
return (
<View
style={[
{height: data.length * itemHeight, position: 'relative'},
t.atoms.bg_contrast_25,
]}
// @ts-expect-error web-only pointer events
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}>
{data.map((item, index) => {
const isActive = dragState?.activeIndex === index
// Clamp translation so the item stays within list bounds.
const rawTranslationY = isActive
? dragState.currentY - dragState.startY
: 0
const translationY = isActive
? Math.max(
-index * itemHeight,
Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight),
)
: 0
// Non-dragged items shift to make room for the dragged item.
let offset = 0
if (dragState && !isActive) {
const orig = dragState.activeIndex
if (orig < newPos && index > orig && index <= newPos) {
offset = -itemHeight
} else if (orig > newPos && index < orig && index >= newPos) {
offset = itemHeight
}
}
const dragHandle = (
<div
onPointerDown={(e: React.PointerEvent<HTMLDivElement>) =>
handlePointerDown(e, index)
}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: 8,
paddingRight: 8,
paddingTop: 12,
paddingBottom: 12,
cursor: isActive ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
}}>
<GripIcon
size="lg"
fill={t.atoms.text_contrast_medium.color}
style={{pointerEvents: 'none'} as any}
/>
</div>
)
return (
<View
key={keyExtractor(item)}
style={[
{
position: 'absolute',
top: index * itemHeight,
left: 0,
right: 0,
height: itemHeight,
transform: [{translateY: isActive ? translationY : offset}],
scale: isActive ? 1.03 : 1,
zIndex: isActive ? 999 : 0,
boxShadow: isActive ? '0 2px 12px rgba(0,0,0,0.06)' : 'none',
// Animate scale/shadow on pickup, and transform for
// non-dragged items shifting into place.
transition: isActive
? 'box-shadow 200ms ease, scale 200ms ease'
: dragState
? 'transform 200ms ease'
: 'none',
} as any,
]}>
{renderItem(item, dragHandle)}
</View>
)
})}
</View>
)
}
+2 -1
View File
@@ -1,6 +1,7 @@
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {CenteredView} from '#/view/com/util/Views'
+2 -1
View File
@@ -6,8 +6,9 @@ import {
AtUri,
RichText as RichTextApi,
} 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 {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from '#/lib/strings/handles'
+2 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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}>
+2 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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'
+2 -1
View File
@@ -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'
+2 -1
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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,
+1 -1
View File
@@ -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'
+3 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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'
+2 -1
View File
@@ -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'
@@ -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,7 +13,7 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -219,7 +219,7 @@ let PostMenuItems = ({
const onToggleThreadMute = () => {
try {
if (isThreadMuted) {
unmuteThread()
void unmuteThread()
ax.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -228,7 +228,7 @@ let PostMenuItems = ({
})
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
void muteThread()
ax.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -239,7 +239,8 @@ let PostMenuItems = ({
_(msg`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(
@@ -253,12 +254,12 @@ let PostMenuItems = ({
const onCopyPostText = () => {
const str = richTextToString(richText, true)
Clipboard.setStringAsync(str)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage)
void translate(record.text, langPrefs.primaryLanguage)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -343,7 +344,8 @@ let PostMenuItems = ({
? _(msg`Quote post was successfully detached`)
: _(msg`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'})),
)
@@ -380,7 +382,8 @@ let PostMenuItems = ({
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
)
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
@@ -409,7 +412,7 @@ let PostMenuItems = ({
const onPressPin = () => {
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({
void pinPostMutate({
postUri,
postCid,
action: isPinned ? 'unpin' : 'pin',
@@ -420,7 +423,8 @@ let PostMenuItems = ({
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
} catch (e: any) {
} 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')
@@ -433,7 +437,8 @@ let PostMenuItems = ({
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
} catch (e: any) {
} 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')
@@ -443,7 +448,8 @@ let PostMenuItems = ({
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
} catch (e: any) {
} 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')
@@ -456,7 +462,7 @@ 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(() => {})
@@ -687,7 +693,7 @@ let PostMenuItems = ({
? _(msg`Unmute account`)
: _(msg`Mute account`)
}
onPress={onMuteAuthor}>
onPress={() => void onMuteAuthor()}>
<Menu.ItemText>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
@@ -796,7 +802,7 @@ let PostMenuItems = ({
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
onConfirm={onToggleQuotePostAttachment}
onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)}
/>
@@ -806,7 +812,7 @@ let PostMenuItems = ({
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}
onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)}
/>
@@ -816,7 +822,7 @@ let PostMenuItems = ({
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={onBlockAuthor}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)}
confirmButtonColor="negative"
/>
@@ -6,12 +6,12 @@ import {
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+2 -1
View File
@@ -1,7 +1,8 @@
import {memo, useCallback} from 'react'
import {View} from 'react-native'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {useRequireAuth} from '#/state/session'
@@ -1,8 +1,7 @@
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useRequireAuth} from '#/state/session'
import {useSession} from '#/state/session'
import {useRequireAuth, useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import {useTheme} from '#/alf'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
@@ -1,7 +1,8 @@
import {ScrollView, View} from 'react-native'
import {moderateProfile, type ModerationOpts} 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 {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
@@ -1,8 +1,9 @@
import {memo, useMemo} from 'react'
import * as ExpoClipboard from 'expo-clipboard'
import {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 {makeProfileLink} from '#/lib/routes/links'
@@ -1,7 +1,8 @@
import {memo, useMemo} from 'react'
import {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 {makeProfileLink} from '#/lib/routes/links'
@@ -7,7 +7,7 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'

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