diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index f75c95052e..8a8388ee80 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -52,6 +52,10 @@ jobs: distribution: 'temurin' java-version: '17' + - name: "Use upgraded MMKV for Fabric" + run: | + sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json + - name: ⚙️ Install dependencies run: yarn install diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 5a4702f964..1b2fba0b75 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - hailey/eas-fab workflow_dispatch: inputs: channel: @@ -118,7 +119,14 @@ jobs: - name: 🏗️ Create Bundle if: ${{ !steps.fingerprint.outputs.includes-changes }} - run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export + run: | + SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export-ios + mv ./dist ./ios-dist + sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json + yarn install + SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }} EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export-android + mv ./dist ./android-dist + - name: 📦 Package Bundle and 🚀 Deploy if: ${{ !steps.fingerprint.outputs.includes-changes }} @@ -275,6 +283,10 @@ jobs: distribution: 'temurin' java-version: '17' + - name: "Use upgraded MMKV for Fabric" + run: | + sed -i 's/"react-native-mmkv": "\^2\.12\.2"/"react-native-mmkv": "^3.3.0"/' package.json + - name: ⚙️ Install dependencies run: yarn install diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc601..c8fec63e2a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx lint-staged +npx lint-staged --concurrent false diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index a5acad25f6..c7a645d39c 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,10 +1,9 @@ -import {deleteAsync} from 'expo-file-system' +import {createDownloadResumable, deleteAsync} from 'expo-file-system' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' -import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, - DownloadAndResizeOpts, + type DownloadAndResizeOpts, getResizedDimensions, } from '../../src/lib/media/manip' @@ -32,11 +31,12 @@ describe('downloadAndResize', () => { }) it('should return resized image for valid URI and options', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image.jpg'), - info: jest.fn().mockReturnValue({status: 200}), - flush: jest.fn(), + const mockedFetch = createDownloadResumable as jest.Mock + mockedFetch.mockReturnValue({ + cancelAsync: jest.fn(), + downloadAsync: jest + .fn() + .mockResolvedValue({uri: 'file://resized-image.jpg'}), }) const opts: DownloadAndResizeOpts = { @@ -50,13 +50,12 @@ describe('downloadAndResize', () => { const result = await downloadAndResize(opts) expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', - }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image.jpg', + expect(createDownloadResumable).toHaveBeenCalledWith( + opts.uri, + expect.anything(), + { + cache: true, + }, ) // First time it gets called is to get dimensions @@ -86,28 +85,6 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for non-200 response', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 400}), - flush: jest.fn(), - }) - - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, - } - - const result = await downloadAndResize(opts) - expect(errorSpy).not.toHaveBeenCalled() - expect(result).toBeUndefined() - }) - it('should not downsize whenever dimensions are below the max dimensions', () => { const initialDimensionsOne = { width: 1200, diff --git a/app.config.js b/app.config.js index 0fa91f2ced..36af084158 100644 --- a/app.config.js +++ b/app.config.js @@ -219,7 +219,7 @@ module.exports = function (_config) { compileSdkVersion: 35, targetSdkVersion: 35, buildToolsVersion: '35.0.0', - newArchEnabled: false, + newArchEnabled: true, }, }, ], diff --git a/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..e34b5eb38f --- /dev/null +++ b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bellRinging_stroke2_corner0_rounded.svg b/assets/icons/bellRinging_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d49a59a01a --- /dev/null +++ b/assets/icons/bellRinging_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/circlePlus_stroke2_corner0_rounded.svg b/assets/icons/circlePlus_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..aa517a2240 --- /dev/null +++ b/assets/icons/circlePlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/likeRepost_stroke2_corner2_rounded.svg b/assets/icons/likeRepost_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..f5d2da35bc --- /dev/null +++ b/assets/icons/likeRepost_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/phoneHaptic_stroke2_corner2_rounded.svg b/assets/icons/phoneHaptic_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..ebcf89b46e --- /dev/null +++ b/assets/icons/phoneHaptic_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/repostRepost_stroke2_corner2_rounded.svg b/assets/icons/repostRepost_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..caec8c1029 --- /dev/null +++ b/assets/icons/repostRepost_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/tree_stroke2_corner0_rounded.svg b/assets/icons/tree_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..b8488c8bef --- /dev/null +++ b/assets/icons/tree_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts index 274a45696f..e34930ca24 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -5,8 +5,6 @@ import {DAY, SECOND} from '@atproto/common' import escapeHTML from 'escape-html' import {type Express} from 'express' -// IMPORTANT: Ensure this import path matches exactly (including casing and extension) everywhere it's used -// Ensure this import path matches exactly everywhere in your project to avoid module duplication import {type AppContext} from '../context.js' import {redirectLogger} from '../logger.js' import {handler} from './util.js' diff --git a/bskylink/src/routes/root.ts b/bskylink/src/routes/root.ts index 12bdf15155..8c6c4afc3b 100644 --- a/bskylink/src/routes/root.ts +++ b/bskylink/src/routes/root.ts @@ -1,6 +1,6 @@ -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {handler} from './util.js' export default function (ctx: AppContext, app: Express) { diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index f419212cc9..ef796920d6 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -278,6 +278,17 @@ func serve(cctx *cli.Context) error { e.GET("/settings/content-and-media", server.WebGeneric) e.GET("/settings/interests", server.WebGeneric) e.GET("/settings/about", server.WebGeneric) + e.GET("/settings/notifications", server.WebGeneric) + e.GET("/settings/notifications/replies", server.WebGeneric) + e.GET("/settings/notifications/mentions", server.WebGeneric) + e.GET("/settings/notifications/quotes", server.WebGeneric) + e.GET("/settings/notifications/likes", server.WebGeneric) + e.GET("/settings/notifications/reposts", server.WebGeneric) + e.GET("/settings/notifications/new-followers", server.WebGeneric) + e.GET("/settings/notifications/likes-on-reposts", server.WebGeneric) + e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric) + e.GET("/settings/notifications/activity", server.WebGeneric) + e.GET("/settings/notifications/miscellaneous", server.WebGeneric) e.GET("/settings/app-icon", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index d303225f6c..700d20afc0 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -33,15 +33,10 @@ jest.mock('react-native-safe-area-context', () => { } }) -jest.mock('rn-fetch-blob', () => ({ - config: jest.fn().mockReturnThis(), - cancel: jest.fn(), - fetch: jest.fn(), -})) - jest.mock('expo-file-system', () => ({ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), deleteAsync: jest.fn(), + createDownloadResumable: jest.fn(), })) jest.mock('expo-image-manipulator', () => ({ @@ -101,7 +96,7 @@ jest.mock('expo-modules-core', () => ({ } } }), - requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + requireNativeViewManager: jest.fn().mockImplementation(_ => { return () => null }), })) diff --git a/package.json b/package.json index ac2171a220..e35dd96b1e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "intl:push": "crowdin push translations --verbose -b main", "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", "update-extensions": "bash scripts/updateExtensions.sh", - "export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps", + "export-ios": "npx expo export --platform ios --dump-sourcemap && yarn upload-native-sourcemaps", + "export-android": "npx expo export --platform android --dump-sourcemap && yarn upload-native-sourcemaps", "upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist", "make-deploy-bundle": "bash scripts/bundleUpdate.sh", "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web", @@ -69,12 +70,12 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.9", + "@atproto/api": "^0.15.16", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", - "@expo/html-elements": "^0.12.4", + "@expo/html-elements": "^0.12.5", "@expo/webpack-config": "^19.0.1", "@floating-ui/dom": "^1.6.3", "@floating-ui/react-dom": "^2.0.8", @@ -85,12 +86,12 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", - "@haileyok/bluesky-video": "0.2.6", + "@haileyok/bluesky-video": "0.3.1", "@ipld/dag-cbor": "^9.2.0", "@lingui/react": "^4.14.1", - "@mattermost/react-native-paste-input": "^0.7.1", - "@miblanchard/react-native-slider": "^2.3.1", - "@mozzius/expo-dynamic-app-icon": "^1.5.0", + "@mattermost/react-native-paste-input": "mattermost/react-native-paste-input", + "@miblanchard/react-native-slider": "^2.6.0", + "@mozzius/expo-dynamic-app-icon": "1.5.0", "@react-native-async-storage/async-storage": "2.1.2", "@react-native-menu/menu": "^1.2.3", "@react-native-picker/picker": "2.11.0", @@ -98,7 +99,7 @@ "@react-navigation/drawer": "^7.3.12", "@react-navigation/native": "^7.1.9", "@react-navigation/native-stack": "^7.3.13", - "@sentry/react-native": "~6.10.0", + "@sentry/react-native": "~6.14.0", "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", "@tanstack/react-query-persist-client": "^5.25.0", @@ -130,33 +131,33 @@ "emoji-mart": "^5.5.2", "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", - "expo": "^53.0.5", + "expo": "53.0.11", "expo-application": "~6.1.4", - "expo-blur": "~14.1.4", + "expo-blur": "~14.1.5", "expo-build-properties": "~0.14.6", - "expo-camera": "~16.1.6", + "expo-camera": "~16.1.8", "expo-clipboard": "~7.1.4", - "expo-dev-client": "~5.1.7", + "expo-dev-client": "~5.2.0", "expo-device": "~7.1.4", - "expo-file-system": "~18.1.8", - "expo-font": "~13.3.0", + "expo-file-system": "~18.1.10", + "expo-font": "~13.3.1", "expo-haptics": "~14.1.4", - "expo-image": "~2.1.6", + "expo-image": "~2.2.1", "expo-image-crop-tool": "^0.1.8", - "expo-image-manipulator": "~13.1.5", + "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", - "expo-linear-gradient": "~14.1.4", - "expo-linking": "~7.1.4", + "expo-linear-gradient": "~14.1.5", + "expo-linking": "~7.1.5", "expo-localization": "~16.1.5", - "expo-media-library": "~17.1.6", - "expo-notifications": "~0.31.1", - "expo-screen-orientation": "~8.1.5", + "expo-media-library": "~17.1.7", + "expo-notifications": "~0.31.3", + "expo-screen-orientation": "~8.1.7", "expo-sharing": "~13.1.5", - "expo-splash-screen": "~0.30.8", - "expo-system-ui": "~5.0.7", + "expo-splash-screen": "~0.30.9", + "expo-system-ui": "~5.0.8", "expo-task-manager": "~13.1.5", - "expo-updates": "~0.28.12", - "expo-video": "~2.1.8", + "expo-updates": "~0.28.14", + "expo-video": "~2.2.1", "expo-web-browser": "~14.1.6", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", @@ -182,35 +183,34 @@ "react-image-crop": "^11.0.7", "react-is": "19", "react-keyed-flatten-children": "^5.0.0", - "react-native": "0.79.2", - "react-native-compressor": "1.11.0", + "react-native": "^0.79.3", + "react-native-compressor": "^1.11.0", "react-native-date-picker": "^5.0.12", - "react-native-drawer-layout": "^4.1.6", + "react-native-drawer-layout": "^4.1.8", "react-native-edge-to-edge": "^1.6.0", "react-native-gesture-handler": "2.25.0", "react-native-get-random-values": "~1.11.0", "react-native-ios-context-menu": "^1.15.3", - "react-native-keyboard-controller": "^1.17.1", + "react-native-keyboard-controller": "^1.17.5", "react-native-mmkv": "^2.12.2", - "react-native-pager-view": "6.7.1", + "react-native-pager-view": "^6.7.1", "react-native-progress": "bluesky-social/react-native-progress", "react-native-qrcode-styled": "^0.3.3", "react-native-reanimated": "~3.17.5", - "react-native-root-siblings": "^4.1.1", + "react-native-root-siblings": "^5.0.1", "react-native-safe-area-context": "5.4.0", "react-native-screens": "^4.11.1", - "react-native-svg": "15.11.2", + "react-native-svg": "15.12.0", "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.20.0", "react-native-web-webview": "^1.0.2", - "react-native-webview": "13.13.5", + "react-native-webview": "^13.13.5", "react-remove-scroll-bar": "^2.3.8", "react-responsive": "^9.0.2", "react-textarea-autosize": "^8.5.3", - "rn-fetch-blob": "^0.12.0", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", @@ -219,7 +219,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.133", + "@atproto/dev-env": "^0.3.144", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/runtime": "^7.26.0", @@ -227,8 +227,9 @@ "@lingui/cli": "^4.14.1", "@lingui/macro": "^4.14.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", - "@react-native/eslint-config": "^0.79.2", - "@react-native/typescript-config": "^0.79.2", + "@react-native/babel-preset": "0.79.3", + "@react-native/eslint-config": "^0.79.3", + "@react-native/typescript-config": "^0.79.3", "@sentry/webpack-plugin": "^3.2.2", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.2.0", @@ -260,7 +261,7 @@ "husky": "^8.0.3", "is-ci": "^3.0.1", "jest": "^29.7.0", - "jest-expo": "~53.0.3", + "jest-expo": "~53.0.7", "jest-junit": "^16.0.0", "lint-staged": "^13.2.3", "lockfile-lint": "^4.14.0", @@ -275,11 +276,11 @@ }, "resolutions": { "@expo/image-utils": "0.6.3", - "@react-native/babel-preset": "0.79.2", - "@react-native/normalize-colors": "0.79.2", + "@react-native/babel-preset": "0.79.3", + "@react-native/normalize-colors": "0.79.3", "@types/react": "^18", "**/expo-constants": "17.0.3", - "**/expo-device": "7.0.1", + "**/expo-device": "7.1.4", "**/zod": "3.23.8", "**/multiformats": "9.9.0" }, @@ -359,7 +360,8 @@ ], "allowedUrls": [ "https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908", - "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4" + "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4", + "https://codeload.github.com/mattermost/react-native-paste-input/tar.gz/f260447edc645a817ab1ba7b46d8341d84dba8e9" ], "emptyHostname": false, "validatePackageNames": true, diff --git a/patches/@mattermost+react-native-paste-input+0.7.1.patch b/patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled similarity index 50% rename from patches/@mattermost+react-native-paste-input+0.7.1.patch rename to patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled index f25b6a776e..a7f1461432 100644 --- a/patches/@mattermost+react-native-paste-input+0.7.1.patch +++ b/patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled @@ -114,7 +114,7 @@ index e916023..5049c33 100644 } #pragma mark - UIScrollViewDelegate -@@ -62,7 +92,6 @@ +@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation { - (void)scrollViewDidScroll:(UIScrollView *)scrollView { RCTDirectEventBlock onScroll = self.onScroll; @@ -122,7 +122,7 @@ index e916023..5049c33 100644 if (onScroll) { CGPoint contentOffset = scrollView.contentOffset; CGSize contentSize = scrollView.contentSize; -@@ -71,22 +100,22 @@ +@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView onScroll(@{ @"contentOffset": @{ @@ -155,3 +155,110 @@ index e916023..5049c33 100644 }, @"zoomScale": @(scrollView.zoomScale ?: 1), }); +diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm +index dd50053..2ed7017 100644 +--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm ++++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm +@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & + const auto &newTextInputProps = static_cast(*props); + + // Traits: +- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) { +- [self _setMultiline:newTextInputProps.traits.multiline]; ++ if (newTextInputProps.multiline != oldTextInputProps.multiline) { ++ [self _setMultiline:newTextInputProps.multiline]; + } + + if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) { +@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection + return; + } + const auto &props = static_cast(*_props); +- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { ++ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { + [self textInputDidChange]; + _ignoreNextTextInputCall = YES; + } +@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe + - (SubmitBehavior)getSubmitBehavior + { + const auto &props = static_cast(*_props); +- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior; ++ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior; + + // We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline. + if (submitBehaviorDefaultable == SubmitBehavior::Default) { +- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit; ++ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit; + } + + return submitBehaviorDefaultable; +diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp +index 29e094f..7ef519a 100644 +--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp ++++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp +@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps( + const PropsParserContext &context, + const PasteTextInputProps &sourceProps, + const RawProps& rawProps) +- : ViewProps(context, sourceProps, rawProps), +- BaseTextProps(context, sourceProps, rawProps), ++ : BaseTextInputProps(context, sourceProps, rawProps), + traits(convertRawProp(context, rawProps, sourceProps.traits, {})), + smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})), + disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})), +@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul + ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const { + auto result = paragraphAttributes; + +- if (!traits.multiline) { ++ if (!multiline) { + result.maximumNumberOfLines = 1; + } + +diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h +index 723d00c..31cfe66 100644 +--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h ++++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h +@@ -15,6 +15,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -25,7 +26,7 @@ + + namespace facebook::react { + +-class PasteTextInputProps final : public ViewProps, public BaseTextProps { ++class PasteTextInputProps final : public BaseTextInputProps { + public: + PasteTextInputProps() = default; + PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps); +diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp +index 31e07e3..7f0ebfb 100644 +--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp ++++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp +@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded( + const auto& state = getStateData(); + + react_native_assert(textLayoutManager_); +- react_native_assert( +- (!state.layoutManager || state.layoutManager == textLayoutManager_) && +- "`StateData` refers to a different `TextLayoutManager`"); +- +- if (state.reactTreeAttributedString == reactTreeAttributedString && +- state.layoutManager == textLayoutManager_) { +- return; +- } + + auto newState = TextInputState{}; + newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString}; + newState.paragraphAttributes = getConcreteProps().paragraphAttributes; + newState.reactTreeAttributedString = reactTreeAttributedString; +- newState.layoutManager = textLayoutManager_; + newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount; + setStateData(std::move(newState)); + } diff --git a/patches/@sentry+react-native+6.10.0.patch b/patches/@sentry+react-native+6.14.0.patch similarity index 100% rename from patches/@sentry+react-native+6.10.0.patch rename to patches/@sentry+react-native+6.14.0.patch diff --git a/patches/expo-media-library+17.1.6.patch b/patches/expo-media-library+17.1.7.patch similarity index 100% rename from patches/expo-media-library+17.1.6.patch rename to patches/expo-media-library+17.1.7.patch diff --git a/patches/expo-modules-core+2.3.12.patch b/patches/expo-modules-core+2.4.0.patch similarity index 100% rename from patches/expo-modules-core+2.3.12.patch rename to patches/expo-modules-core+2.4.0.patch diff --git a/patches/expo-modules-core+2.3.12.patch.md b/patches/expo-modules-core+2.4.0.patch.md similarity index 100% rename from patches/expo-modules-core+2.3.12.patch.md rename to patches/expo-modules-core+2.4.0.patch.md diff --git a/patches/expo-notifications+0.31.1.patch b/patches/expo-notifications+0.31.3.patch similarity index 100% rename from patches/expo-notifications+0.31.1.patch rename to patches/expo-notifications+0.31.3.patch diff --git a/patches/expo-notifications+0.31.1.patch.md b/patches/expo-notifications+0.31.3.patch.md similarity index 100% rename from patches/expo-notifications+0.31.1.patch.md rename to patches/expo-notifications+0.31.3.patch.md diff --git a/patches/expo-updates+0.28.12.patch b/patches/expo-updates+0.28.14.patch similarity index 100% rename from patches/expo-updates+0.28.12.patch rename to patches/expo-updates+0.28.14.patch diff --git a/patches/expo-updates+0.28.12.patch.md b/patches/expo-updates+0.28.14.patch.md similarity index 100% rename from patches/expo-updates+0.28.12.patch.md rename to patches/expo-updates+0.28.14.patch.md diff --git a/patches/react-native+0.79.2.patch b/patches/react-native+0.79.3.patch similarity index 70% rename from patches/react-native+0.79.2.patch rename to patches/react-native+0.79.3.patch index 609ae66178..6d465475eb 100644 --- a/patches/react-native+0.79.2.patch +++ b/patches/react-native+0.79.3.patch @@ -1,3 +1,32 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h +index 914a249..0deac55 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h +@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN + */ + @interface RCTPullToRefreshViewComponentView : RCTViewComponentView + ++- (void)beginRefreshingProgrammatically; ++ + @end + + NS_ASSUME_NONNULL_END +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +index d029337..0f63ea3 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +@@ -1003,6 +1003,11 @@ - (void)_adjustForMaintainVisibleContentPosition + } + } + +++ (BOOL)shouldBeRecycled ++{ ++ return NO; ++} ++ + @end + + Class RCTScrollViewCls(void) diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h index e9b330f..ec5f58c 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h @@ -15,7 +44,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont index 53bfd04..ff1b1ed 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -@@ -23,6 +23,7 @@ +@@ -23,6 +23,7 @@ @implementation RCTRefreshControl { UIColor *_titleColor; CGFloat _progressViewOffset; BOOL _hasMovedToWindow; @@ -23,7 +52,7 @@ index 53bfd04..ff1b1ed 100644 } - (instancetype)init -@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder) +@@ -58,6 +59,12 @@ - (void)layoutSubviews _isInitialRender = false; } @@ -36,7 +65,7 @@ index 53bfd04..ff1b1ed 100644 - (void)didMoveToWindow { [super didMoveToWindow]; -@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder) +@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged } } @@ -91,7 +120,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont index 40aaf9c..1c60164 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m -@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE() +@@ -22,11 +22,12 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL) @@ -105,15 +134,3 @@ index 40aaf9c..1c60164 100644 RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing) { [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { -diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m -index cd1e7eb..c1d0172 100644 ---- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m -+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m -@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL) - RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval) - RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat) - RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets) -+RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets) - RCT_EXPORT_VIEW_PROPERTY(verticalScrollIndicatorInsets, UIEdgeInsets) - RCT_EXPORT_VIEW_PROPERTY(scrollToOverflowEnabled, BOOL) - RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int) diff --git a/patches/react-native+0.79.2.patch.md b/patches/react-native+0.79.3.patch.md similarity index 100% rename from patches/react-native+0.79.2.patch.md rename to patches/react-native+0.79.3.patch.md diff --git a/patches/react-native-svg+15.11.2.patch b/patches/react-native-svg+15.12.0.patch similarity index 100% rename from patches/react-native-svg+15.11.2.patch rename to patches/react-native-svg+15.12.0.patch diff --git a/scripts/bundleUpdate.js b/scripts/bundleUpdate.js index 00217dcd7b..a5f1fb641d 100644 --- a/scripts/bundleUpdate.js +++ b/scripts/bundleUpdate.js @@ -3,17 +3,25 @@ const fs = require('fs') const fsp = fs.promises const path = require('path') -const DIST_DIR = './dist' +const IOS_DIST_DIR = './ios-dist' +const ANDROID_DIST_DIR = './android-dist' + const BUNDLES_DIR = '/_expo/static/js' -const IOS_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/ios') -const ANDROID_BUNDLE_DIR = path.join(DIST_DIR, BUNDLES_DIR, '/android') -const METADATA_PATH = path.join(DIST_DIR, '/metadata.json') + +const IOS_BUNDLE_DIR = path.join(IOS_DIST_DIR, BUNDLES_DIR, '/ios') +const ANDROID_BUNDLE_DIR = path.join(ANDROID_DIST_DIR, BUNDLES_DIR, '/android') + +const IOS_METADATA_PATH = path.join(IOS_DIST_DIR, '/metadata.json') +const ANDROID_METADATA_PATH = path.join(ANDROID_DIST_DIR, '/metadata.json') + const DEST_DIR = './bundleTempDir' // Weird, don't feel like figuring out _why_ it wants this -const METADATA = require(`../${METADATA_PATH}`) -const IOS_METADATA_ASSETS = METADATA.fileMetadata.ios.assets -const ANDROID_METADATA_ASSETS = METADATA.fileMetadata.android.assets +const IOS_METADATA = require(`../${IOS_METADATA_PATH}`) +const ANDROID_METADATA = require(`../${ANDROID_METADATA_PATH}`) + +const IOS_METADATA_ASSETS = IOS_METADATA.fileMetadata.ios.assets +const ANDROID_METADATA_ASSETS = ANDROID_METADATA.fileMetadata.android.assets const getMd5 = async path => { return new Promise(res => { @@ -60,7 +68,7 @@ const moveFiles = async () => { console.log('Getting ios asset md5s and moving them...') for (const asset of IOS_METADATA_ASSETS) { - const currPath = path.join(DIST_DIR, asset.path) + const currPath = path.join(IOS_DIST_DIR, asset.path) const md5 = await getMd5(currPath) const withExtPath = `assets/${md5}.${asset.ext}` iosAssets.push(withExtPath) @@ -69,7 +77,7 @@ const moveFiles = async () => { console.log('Getting android asset md5s and moving them...') for (const asset of ANDROID_METADATA_ASSETS) { - const currPath = path.join(DIST_DIR, asset.path) + const currPath = path.join(ANDROID_DIST_DIR, asset.path) const md5 = await getMd5(currPath) const withExtPath = `assets/${md5}.${asset.ext}` androidAssets.push(withExtPath) diff --git a/src/App.native.tsx b/src/App.native.tsx index e3f85c0fe5..81d4a870e9 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -58,9 +58,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' import {TestCtrls} from '#/view/com/testing/TestCtrls' -import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -70,9 +68,11 @@ import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' SplashScreen.preventAutoHideAsync() if (isIOS) { @@ -151,7 +151,7 @@ function InnerApp() { - + @@ -160,7 +160,7 @@ function InnerApp() { - + diff --git a/src/App.web.tsx b/src/App.web.tsx index 97ada61485..b706774fdc 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -48,9 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' -import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' -import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' @@ -61,7 +58,10 @@ import {NuxDialogs} from '#/components/dialogs/nuxs' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' +import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' /** * Begin geolocation ASAP @@ -132,12 +132,12 @@ function InnerApp() { - + - + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2f26c09711..f1a9c569d0 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -90,11 +90,10 @@ import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' +import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' -import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' import {SettingsScreen} from '#/screens/Settings/Settings' -import {SettingsInterests} from '#/screens/Settings/SettingsInterests' import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' import { StarterPackScreen, @@ -110,6 +109,17 @@ import { } from '#/components/dialogs/EmailDialog' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' +import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' +import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' +import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings' +import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings' +import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings' +import {MiscellaneousNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MiscellaneousNotificationSettings' +import {NewFollowerNotificationSettingsScreen} from './screens/Settings/NotificationSettings/NewFollowerNotificationSettings' +import {QuoteNotificationSettingsScreen} from './screens/Settings/NotificationSettings/QuoteNotificationSettings' +import {ReplyNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ReplyNotificationSettings' +import {RepostNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostNotificationSettings' +import {RepostsOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings' const navigationRef = createNavigationContainerRef() @@ -380,6 +390,83 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { requireAuth: true, }} /> + NotificationSettingsScreen} + options={{title: title(msg`Notification settings`), requireAuth: true}} + /> + ReplyNotificationSettingsScreen} + options={{ + title: title(msg`Reply notifications`), + requireAuth: true, + }} + /> + MentionNotificationSettingsScreen} + options={{ + title: title(msg`Mention notifications`), + requireAuth: true, + }} + /> + QuoteNotificationSettingsScreen} + options={{ + title: title(msg`Quote notifications`), + requireAuth: true, + }} + /> + LikeNotificationSettingsScreen} + options={{ + title: title(msg`Like notifications`), + requireAuth: true, + }} + /> + RepostNotificationSettingsScreen} + options={{ + title: title(msg`Repost notifications`), + requireAuth: true, + }} + /> + NewFollowerNotificationSettingsScreen} + options={{ + title: title(msg`New follower notifications`), + requireAuth: true, + }} + /> + LikesOnRepostsNotificationSettingsScreen} + options={{ + title: title(msg`Likes of your reposts notifications`), + requireAuth: true, + }} + /> + RepostsOnRepostsNotificationSettingsScreen} + options={{ + title: title(msg`Reposts of your reposts notifications`), + requireAuth: true, + }} + /> + MiscellaneousNotificationSettingsScreen} + options={{ + title: title(msg`Miscellaneous notifications`), + requireAuth: true, + }} + /> ContentAndMediaSettingsScreen} @@ -389,8 +476,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { }} /> SettingsInterests} + name="InterestsSettings" + getComponent={() => InterestsSettingsScreen} options={{ title: title(msg`Your interests`), requireAuth: true, @@ -438,8 +525,8 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { options={{title: title(msg`Chat request inbox`), requireAuth: true}} /> NotificationSettingsScreen} + name="LegacyNotificationSettings" + getComponent={() => LegacyNotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} /> }, + + pointer: web({ + cursor: 'pointer', + }), } as const diff --git a/src/alf/util/__tests__/colors.test.ts b/src/alf/util/__tests__/colors.test.ts new file mode 100644 index 0000000000..350b6ff4a4 --- /dev/null +++ b/src/alf/util/__tests__/colors.test.ts @@ -0,0 +1,48 @@ +import {jest} from '@jest/globals' + +import {logger} from '#/logger' +import {transparentifyColor} from '../colorGeneration' + +jest.mock('#/logger', () => ({ + logger: {warn: jest.fn()}, +})) + +describe('transparentifyColor', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('converts hsl() to hsla()', () => { + const result = transparentifyColor('hsl(120 100% 50%)', 0.5) + expect(result).toBe('hsla(120 100% 50%, 0.5)') + }) + + it('converts hsl() to hsla() - fully transparent', () => { + const result = transparentifyColor('hsl(120 100% 50%)', 0) + expect(result).toBe('hsla(120 100% 50%, 0)') + }) + + it('converts rgb() to rgba()', () => { + const result = transparentifyColor('rgb(255 0 0)', 0.75) + expect(result).toBe('rgba(255 0 0, 0.75)') + }) + + it('expands 3-digit hex and appends alpha channel', () => { + const result = transparentifyColor('#abc', 0.4) + expect(result).toBe('#aabbcc66') + }) + + it('appends alpha to 6-digit hex', () => { + const result = transparentifyColor('#aabbcc', 0.4) + expect(result).toBe('#aabbcc66') + }) + + it('returns the original string and warns for unsupported formats', () => { + const unsupported = 'blue' + const result = transparentifyColor(unsupported, 0.5) + expect(result).toBe(unsupported) + expect(logger.warn).toHaveBeenCalledWith( + `Could not make '${unsupported}' transparent`, + ) + }) +}) diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts index 8d769b51b1..574ab0a496 100644 --- a/src/alf/util/colorGeneration.ts +++ b/src/alf/util/colorGeneration.ts @@ -1,3 +1,5 @@ +import {logger} from '#/logger' + export const BLUE_HUE = 211 export const RED_HUE = 346 export const GREEN_HUE = 152 @@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) { export const defaultScale = generateScale(6, 100) // dim shifted 6% lighter export const dimScale = generateScale(12, 100) + +export function transparentifyColor(color: string, alpha: number) { + if (color.startsWith('hsl(')) { + return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})` + } else if (color.startsWith('rgb(')) { + return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})` + } else if (color.startsWith('#')) { + if (color.length === 7) { + const alphaHex = Math.round(alpha * 255).toString(16) + // Per MDN: If there is only one number, it is duplicated: e means ee + // https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color + return color.slice(0, 7) + alphaHex.padStart(2, alphaHex) + } else if (color.length === 4) { + // convert to 6-digit hex before adding alpha + const [r, g, b] = color.slice(1).split('') + const alphaHex = Math.round(alpha * 255).toString(16) + return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart( + 2, + alphaHex, + )}` + } + } else { + logger.warn(`Could not make '${color}' transparent`) + } + return color +} diff --git a/src/alf/util/systemUI.ts b/src/alf/util/systemUI.ts index c973e10ea6..9e5769c4c6 100644 --- a/src/alf/util/systemUI.ts +++ b/src/alf/util/systemUI.ts @@ -1,7 +1,7 @@ import * as SystemUI from 'expo-system-ui' import {isAndroid} from '#/platform/detection' -import {Theme} from '../types' +import {type Theme} from '../types' export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) { if (isAndroid) { diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx index 027bf9849a..37fcebf493 100644 --- a/src/components/ContextMenu/Backdrop.tsx +++ b/src/components/ContextMenu/Backdrop.tsx @@ -2,7 +2,7 @@ import {Pressable} from 'react-native' import Animated, { Extrapolation, interpolate, - SharedValue, + type SharedValue, useAnimatedStyle, } from 'react-native-reanimated' import {msg} from '@lingui/macro' diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 6ecc3f5a87..a92e7be7f2 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,24 +1,30 @@ import React from 'react' import {View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' -import {AppBskyFeedDefs, AtUri} from '@atproto/api' +import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' -import {FeedDescriptor} from '#/state/queries/post-feed' +import {type FeedDescriptor} from '#/state/queries/post-feed' import {useProfilesQuery} from '#/state/queries/profile' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' -import {SeenPost} from '#/state/userActionHistory' +import {type SeenPost} from '#/state/userActionHistory' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' -import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf' +import { + atoms as a, + useBreakpoints, + useTheme, + type ViewStyleProp, + web, +} from '#/alf' import {Button} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' @@ -27,7 +33,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' import {ProgressGuideList} from './ProgressGuide/List' const MOBILE_CARD_WIDTH = 300 diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 44faa96498..d68f4bd1d2 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -1,24 +1,24 @@ import {createContext, useCallback, useContext} from 'react' -import {GestureResponderEvent, Keyboard, View} from 'react-native' +import {type GestureResponderEvent, Keyboard, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {HITSLOP_30} from '#/lib/constants' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {isIOS} from '#/platform/detection' import {useSetDrawerOpen} from '#/state/shell' import { atoms as a, platform, - TextStyleProp, + type TextStyleProp, useBreakpoints, useGutters, useLayoutBreakpoints, useTheme, web, } from '#/alf' -import {Button, ButtonIcon, ButtonProps} from '#/components/Button' +import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import { diff --git a/src/components/Link.tsx b/src/components/Link.tsx index d73a3db4ae..28cd194185 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -4,13 +4,12 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import { type LinkProps as RNLinkProps, StackActions, - useLinkBuilder, } from '@react-navigation/native' import {BSKY_DOWNLOAD_URL} from '#/lib/constants' import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {type AllNavigatorParams} from '#/lib/routes/types' +import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import { convertBskyAppUrlIfNeeded, @@ -25,6 +24,7 @@ import {Button, type ButtonProps} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Text, type TextProps} from '#/components/Typography' import {router} from '#/routes' +import {useGlobalDialogsControlContext} from './dialogs/Context' /** * Only available within a `Link`, since that inherits from `Button`. @@ -95,25 +95,25 @@ export function useLink({ shouldProxy?: boolean }) { const navigation = useNavigationDeduped() - const {buildHref} = useLinkBuilder() const href = useMemo(() => { return typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to.screen - ? buildHref(to.screen, to.params) + ? router.matchName(to.screen)?.build(to.params) : to.href ? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href)) : undefined - }, [to, buildHref]) + }, [to]) if (!href) { throw new Error( - 'Link `to` prop must be a string or an object with `screen` and `params` properties', + 'Could not resolve screen. Link `to` prop must be a string or an object with `screen` and `params` properties', ) } const isExternal = isExternalUrl(href) - const {openModal, closeModal} = useModalControls() + const {closeModal} = useModalControls() + const {linkWarningDialogControl} = useGlobalDialogsControlContext() const openLink = useOpenLink() const onPress = React.useCallback( @@ -134,10 +134,9 @@ export function useLink({ } if (requiresWarning) { - openModal({ - name: 'link-warning', - text: displayText, - href: href, + linkWarningDialogControl.open({ + displayText, + href, }) } else { if (isExternal) { @@ -156,15 +155,44 @@ export function useLink({ } else { closeModal() // close any active modals + const [screen, params] = router.matchPath(href) as [ + screen: keyof AllNavigatorParams, + params?: RouteParams, + ] + + // does not apply to web's flat navigator + if (isNative && screen !== 'NotFound') { + const state = navigation.getState() + // if screen is not in the current navigator, it means it's + // most likely a tab screen + if (!state.routeNames.includes(screen)) { + const parent = navigation.getParent() + if ( + parent && + parent.getState().routeNames.includes(`${screen}Tab`) + ) { + // yep, it's a tab screen. i.e. SearchTab + // thus we need to navigate to the child screen + // via the parent navigator + // see https://reactnavigation.org/docs/upgrading-from-6.x/#changes-to-the-navigate-action + // TODO: can we support the other kinds of actions? push/replace -sfn + + // @ts-expect-error include does not narrow the type unfortunately + parent.navigate(`${screen}Tab`, {screen, params}) + return + } else { + // will probably fail, but let's try anyway + } + } + } + if (action === 'push') { - navigation.dispatch(StackActions.push(...router.matchPath(href))) + navigation.dispatch(StackActions.push(screen, params)) } else if (action === 'replace') { - navigation.dispatch( - StackActions.replace(...router.matchPath(href)), - ) + navigation.dispatch(StackActions.replace(screen, params)) } else if (action === 'navigate') { - // @ts-ignore - navigation.navigate(...router.matchPath(href)) + // @ts-expect-error not typed + navigation.navigate(screen, params) } else { throw Error('Unsupported navigator action.') } @@ -178,13 +206,13 @@ export function useLink({ displayText, isExternal, href, - openModal, openLink, closeModal, action, navigation, overridePresentation, shouldProxy, + linkWarningDialogControl, ], ) @@ -197,16 +225,21 @@ export function useLink({ ) if (requiresWarning) { - openModal({ - name: 'link-warning', - text: displayText, - href: href, + linkWarningDialogControl.open({ + displayText, + href, share: true, }) } else { shareUrl(href) } - }, [disableMismatchWarning, displayText, href, isExternal, openModal]) + }, [ + disableMismatchWarning, + displayText, + href, + isExternal, + linkWarningDialogControl, + ]) const onLongPress = React.useCallback( (e: GestureResponderEvent) => { diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx index d810a03de4..076bc81511 100644 --- a/src/components/Menu/context.tsx +++ b/src/components/Menu/context.tsx @@ -1,6 +1,6 @@ import React from 'react' -import type {ContextType, ItemContextType} from '#/components/Menu/types' +import {type ContextType, type ItemContextType} from '#/components/Menu/types' export const Context = React.createContext(null) diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx similarity index 94% rename from src/view/com/util/post-embeds/ExternalGifEmbed.tsx rename to src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx index 39c1d109e1..0c8f30d2b9 100644 --- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx +++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx @@ -1,11 +1,15 @@ import React from 'react' -import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native' +import { + ActivityIndicator, + type GestureResponderEvent, + Pressable, +} from 'react-native' import {Image} from 'expo-image' -import {AppBskyEmbedExternal} from '@atproto/api' +import {type AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {EmbedPlayerParams} from '#/lib/strings/embed-player' +import {type EmbedPlayerParams} from '#/lib/strings/embed-player' import {isIOS, isNative, isWeb} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' import {atoms as a, useTheme} from '#/alf' @@ -14,7 +18,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' import {Fill} from '#/components/Fill' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -export function ExternalGifEmbed({ +export function ExternalGif({ link, params, }: { diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx similarity index 96% rename from src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx rename to src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx index e78abdf176..392cdd8a10 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx @@ -1,7 +1,7 @@ import React from 'react' import { ActivityIndicator, - GestureResponderEvent, + type GestureResponderEvent, Pressable, StyleSheet, useWindowDimensions, @@ -16,21 +16,24 @@ import Animated, { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {WebView} from 'react-native-webview' import {Image} from 'expo-image' -import {AppBskyEmbedExternal} from '@atproto/api' +import {type AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' -import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player' +import {type NavigationProp} from '#/lib/routes/types' +import { + type EmbedPlayerParams, + getPlayerAspect, +} from '#/lib/strings/embed-player' import {isNative} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' +import {EventStopper} from '#/view/com/util/EventStopper' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' import {Fill} from '#/components/Fill' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -import {EventStopper} from '../EventStopper' interface ShouldStartLoadRequest { url: string diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx similarity index 96% rename from src/view/com/util/post-embeds/GifEmbed.tsx rename to src/components/Post/Embed/ExternalEmbed/Gif.tsx index a839294f1c..8e84997314 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -1,17 +1,17 @@ import React from 'react' import { Pressable, - StyleProp, + type StyleProp, StyleSheet, TouchableOpacity, View, - ViewStyle, + type ViewStyle, } from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_20} from '#/lib/constants' -import {EmbedPlayerParams} from '#/lib/strings/embed-player' +import {type EmbedPlayerParams} from '#/lib/strings/embed-player' import {isWeb} from '#/platform/detection' import {useAutoplayDisabled} from '#/state/preferences' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' @@ -22,7 +22,7 @@ import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' -import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' +import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' function PlaybackControls({ onPress, diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx similarity index 94% rename from src/view/com/util/post-embeds/ExternalLinkEmbed.tsx rename to src/components/Post/Embed/ExternalEmbed/index.tsx index 7ca11f60d0..714eaecd63 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -12,16 +12,16 @@ import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' import {toNiceDomain} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' -import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed' -import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed' -import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed' import {atoms as a, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {Link} from '#/components/Link' import {Text} from '#/components/Typography' +import {ExternalGif} from './ExternalGif' +import {ExternalPlayer} from './ExternalPlayer' +import {GifEmbed} from './Gif' -export const ExternalLinkEmbed = ({ +export const ExternalEmbed = ({ link, onOpen, style, @@ -106,7 +106,7 @@ export const ExternalLinkEmbed = ({ ) : undefined} {embedPlayerParams?.isGif ? ( - + ) : embedPlayerParams ? ( ) : undefined} diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx new file mode 100644 index 0000000000..fad4cd4d8b --- /dev/null +++ b/src/components/Post/Embed/FeedEmbed.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {moderateFeedGenerator} from '@atproto/api' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' +import {ContentHider} from '#/components/moderation/ContentHider' +import {type EmbedType} from '#/types/bsky/post' +import {type CommonProps} from './types' + +export function FeedEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + const pal = usePalette('default') + return ( + + ) +} + +export function ModeratedFeedEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderateFeedGenerator(embed.view, moderationOpts) + : undefined + }, [embed.view, moderationOpts]) + return ( + + + + ) +} + +const styles = StyleSheet.create({ + customFeedOuter: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }, +}) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx new file mode 100644 index 0000000000..030d237a03 --- /dev/null +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -0,0 +1,106 @@ +import {InteractionManager, View} from 'react-native' +import { + type AnimatedRef, + measure, + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' +import {Image} from 'expo-image' + +import {useLightboxControls} from '#/state/lightbox' +import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' +import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage' +import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid' +import {atoms as a} from '#/alf' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {type EmbedType} from '#/types/bsky/post' +import {type CommonProps} from './types' + +export function ImageEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'images'> +}) { + const {openLightbox} = useLightboxControls() + const {images} = embed.view + + if (images.length > 0) { + const items = images.map(img => ({ + uri: img.fullsize, + thumbUri: img.thumb, + alt: img.alt, + dimensions: img.aspectRatio ?? null, + })) + const _openLightbox = ( + index: number, + thumbRects: (MeasuredDimensions | null)[], + fetchedDims: (Dimensions | null)[], + ) => { + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: thumbRects[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) + } + const onPress = ( + index: number, + refs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => { + runOnUI(() => { + 'worklet' + const rects: (MeasuredDimensions | null)[] = [] + for (const r of refs) { + rects.push(measure(r)) + } + runOnJS(_openLightbox)(index, rects, fetchedDims) + })() + } + const onPressIn = (_: number) => { + InteractionManager.runAfterInteractions(() => { + Image.prefetch(items.map(i => i.uri)) + }) + } + + if (images.length === 1) { + const image = images[0] + return ( + + onPress(0, [containerRef], [dims])} + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + + ) + } + + return ( + + + + ) + } +} diff --git a/src/components/Post/Embed/LazyQuoteEmbed.tsx b/src/components/Post/Embed/LazyQuoteEmbed.tsx new file mode 100644 index 0000000000..fdc1c63091 --- /dev/null +++ b/src/components/Post/Embed/LazyQuoteEmbed.tsx @@ -0,0 +1,37 @@ +import {useMemo} from 'react' +import {View} from 'react-native' + +import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util' +import {useResolveLinkQuery} from '#/state/queries/resolve-link' +import {atoms as a, useTheme} from '#/alf' +import {QuoteEmbed} from '#/components/Post/Embed' + +export function LazyQuoteEmbed({uri}: {uri: string}) { + const t = useTheme() + const {data} = useResolveLinkQuery(uri) + + const view = useMemo(() => { + if (!data || data.type !== 'record' || data.kind !== 'post') return + return createEmbedViewRecordFromPost(data.view) + }, [data]) + + return view ? ( + + ) : ( + + ) +} diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx new file mode 100644 index 0000000000..82685d2715 --- /dev/null +++ b/src/components/Post/Embed/ListEmbed.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {View} from 'react-native' +import {moderateUserList} from '@atproto/api' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' +import {ContentHider} from '#/components/moderation/ContentHider' +import {type EmbedType} from '#/types/bsky/post' +import {type CommonProps} from './types' + +export function ListEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'list'> +}) { + const t = useTheme() + return ( + + + + ) +} + +export function ModeratedListEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'list'> +}) { + const moderationOpts = useModerationOpts() + const moderation = React.useMemo(() => { + return moderationOpts + ? moderateUserList(embed.view, moderationOpts) + : undefined + }, [embed.view, moderationOpts]) + return ( + + + + ) +} diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx new file mode 100644 index 0000000000..8402340269 --- /dev/null +++ b/src/components/Post/Embed/PostPlaceholder.tsx @@ -0,0 +1,33 @@ +import {StyleSheet, View} from 'react-native' + +import {usePalette} from '#/lib/hooks/usePalette' +import {InfoCircleIcon} from '#/lib/icons' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, useTheme} from '#/alf' + +export function PostPlaceholder({children}: {children: React.ReactNode}) { + const t = useTheme() + const pal = usePalette('default') + return ( + + + + {children} + + + ) +} + +const styles = StyleSheet.create({ + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx similarity index 100% rename from src/view/com/util/post-embeds/ActiveVideoWebContext.tsx rename to src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx similarity index 95% rename from src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx index 95401309f4..67af7618c3 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx @@ -1,4 +1,4 @@ -import {StyleProp, ViewStyle} from 'react-native' +import {type StyleProp, type ViewStyle} from 'react-native' import {View} from 'react-native' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx similarity index 96% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index 8b44f54483..351e9f3056 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,13 +1,12 @@ import React, {useRef} from 'react' -import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import {AppBskyEmbedVideo} from '@atproto/api' +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 {useLingui} from '@lingui/react' import {HITSLOP_30} from '#/lib/constants' import {useAutoplayDisabled} from '#/state/preferences' -import {useVideoMuteState} from '#/view/com/util/post-embeds/VideoVolumeContext' import {atoms as a, useTheme} from '#/alf' import {useIsWithinMessage} from '#/components/dms/MessageContext' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' @@ -15,6 +14,7 @@ import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Paus import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {TimeIndicator} from './TimeIndicator' export const VideoEmbedInnerNative = React.forwardRef( diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx similarity index 98% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 77f6cd0a6c..ce3a7b2c90 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useId, useRef, useState} from 'react' +import {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/macro' @@ -28,7 +28,7 @@ export function VideoEmbedInnerWeb({ const videoRef = useRef(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) - const [hlsLoading, setHlsLoading] = React.useState(false) + const [hlsLoading, setHlsLoading] = useState(false) const figId = useId() const {_} = useLingui() @@ -101,8 +101,8 @@ export function VideoEmbedInnerWeb({ fullscreenRef={containerRef} hasSubtitleTrack={hasSubtitleTrack} /> - + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx similarity index 97% rename from src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx index 1b46163cce..37b44751d1 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx @@ -1,7 +1,7 @@ -import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import type React from 'react' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts similarity index 100% rename from src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx similarity index 87% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx index 6510464453..9b0c963eaf 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx @@ -1,8 +1,8 @@ -import React from 'react' -import {SvgProps} from 'react-native-svg' +import {type SvgProps} from 'react-native-svg' +import type React from 'react' +import {PressableWithHover} from '#/view/com/util/PressableWithHover' import {atoms as a, useTheme, web} from '#/alf' -import {PressableWithHover} from '../../../PressableWithHover' export function ControlButton({ active, diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx similarity index 98% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx index 96960bad47..d84a90fa62 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -1,7 +1,8 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import type React from 'react' import {isFirefox, isTouchDevice} from '#/lib/browser' import {clamp} from '#/lib/numbers' diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx similarity index 99% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 8e134d2217..6d14deafc0 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import {useCallback, useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx similarity index 95% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx index 90ffb9e6b1..ec5f23fc07 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -1,14 +1,15 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import {View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import type React from 'react' import {isSafari, isTouchDevice} from '#/lib/browser' import {atoms as a} from '#/alf' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' -import {useVideoVolumeState} from '../../VideoVolumeContext' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {ControlButton} from './ControlButton' export function VolumeControl({ diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx similarity index 96% rename from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx rename to src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx index 108814ea2b..320f61a5f8 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -1,9 +1,9 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' import {isSafari} from '#/lib/browser' -import {useVideoVolumeState} from '../../VideoVolumeContext' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' -export function useVideoElement(ref: React.RefObject) { +export function useVideoElement(ref: RefObject) { const [playing, setPlaying] = useState(false) const [muted, setMuted] = useState(true) const [currentTime, setCurrentTime] = useState(0) diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoVolumeContext.tsx rename to src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx similarity index 95% rename from src/view/com/util/post-embeds/VideoEmbed.tsx rename to src/components/Post/Embed/VideoEmbed/index.tsx index b45027089a..8cb78ff70b 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -1,17 +1,17 @@ import React, {useCallback, useState} from 'react' import {ActivityIndicator, View} from 'react-native' import {ImageBackground} from 'expo-image' -import {AppBskyEmbedVideo} from '@atproto/api' +import {type AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -import {ErrorBoundary} from '../ErrorBoundary' +import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' import * as VideoFallback from './VideoEmbedInner/VideoFallback' interface Props { diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx similarity index 95% rename from src/view/com/util/post-embeds/VideoEmbed.web.tsx rename to src/components/Post/Embed/VideoEmbed/index.web.tsx index b0ded67548..7f601af47b 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -1,20 +1,21 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' -import {AppBskyEmbedVideo} from '@atproto/api' +import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import type React from 'react' import {isFirefox} from '#/lib/browser' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' +import {atoms as a} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' import { HLSUnsupportedError, VideoEmbedInnerWeb, VideoNotFoundError, -} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' -import {atoms as a} from '#/alf' -import {useIsWithinMessage} from '#/components/dms/MessageContext' -import {useFullscreen} from '#/components/hooks/useFullscreen' -import {ErrorBoundary} from '../ErrorBoundary' +} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx new file mode 100644 index 0000000000..ace85dc984 --- /dev/null +++ b/src/components/Post/Embed/index.tsx @@ -0,0 +1,332 @@ +import React from 'react' +import {View} from 'react-native' +import { + type $Typed, + type AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {Link} from '#/view/com/util/Link' +import {PostMeta} from '#/view/com/util/PostMeta' +import {atoms as a, useTheme} from '#/alf' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import * as bsky from '#/types/bsky' +import { + type Embed as TEmbed, + type EmbedType, + parseEmbed, +} from '#/types/bsky/post' +import {ExternalEmbed} from './ExternalEmbed' +import {ModeratedFeedEmbed} from './FeedEmbed' +import {ImageEmbed} from './ImageEmbed' +import {ModeratedListEmbed} from './ListEmbed' +import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder' +import { + type CommonProps, + type EmbedProps, + PostEmbedViewContext, + QuoteEmbedViewContext, +} from './types' +import {VideoEmbed} from './VideoEmbed' + +export {PostEmbedViewContext, QuoteEmbedViewContext} from './types' + +export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { + const embed = parseEmbed(rawEmbed) + + switch (embed.type) { + case 'images': + case 'link': + case 'video': { + return + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return + } + case 'post_with_media': { + return ( + + + + + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'images': { + return ( + + + + ) + } + case 'link': { + return ( + + + + ) + } + case 'video': { + return ( + + + + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: TEmbed +}) { + switch (embed.type) { + case 'feed': { + return ( + + + + ) + } + case 'list': { + return ( + + + + ) + } + case 'starter_pack': { + return ( + + + + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + if (rest.isWithinQuote && !rest.allowNestedQuotes) { + return null + } + + return ( + + ) + } + case 'post_not_found': { + return ( + + Deleted + + ) + } + case 'post_blocked': { + return ( + + Blocked + + ) + } + case 'post_detached': { + return + } + default: { + return null + } + } +} + +export function PostDetachedEmbed({ + embed, +}: { + embed: EmbedType<'post_detached'> +}) { + const {currentAccount} = useSession() + const isViewerOwner = currentAccount?.did + ? embed.view.uri.includes(currentAccount.did) + : false + + return ( + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + + ) +} + +/* + * Nests parent `Embed` component and therefore must live in this file to avoid + * circular imports. + */ +export function QuoteEmbed({ + embed, + onOpen, + style, + isWithinQuote: parentIsWithinQuote, + allowNestedQuotes: parentAllowNestedQuotes, +}: Omit & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed>( + () => ({ + ...embed.view, + $type: 'app.bsky.feed.defs#postView', + record: embed.view.value, + embed: embed.view.embeds?.[0], + }), + [embed], + ) + const moderation = React.useMemo(() => { + return moderationOpts ? moderatePost(quote, moderationOpts) : undefined + }, [quote, moderationOpts]) + + const t = useTheme() + const queryClient = useQueryClient() + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${quote.author.handle}` + + const richText = React.useMemo(() => { + if ( + !bsky.dangerousIsType( + quote.record, + AppBskyFeedPost.isRecord, + ) + ) + return undefined + const {text, facets} = quote.record + return text.trim() + ? new RichTextAPI({text: text, facets: facets}) + : undefined + }, [quote.record]) + + const onBeforePress = React.useCallback(() => { + unstableCacheProfileView(queryClient, quote.author) + onOpen?.() + }, [queryClient, quote.author, onOpen]) + + const [hover, setHover] = React.useState(false) + return ( + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + + + + {moderation ? ( + + ) : null} + {richText ? ( + + ) : null} + {quote.embed && ( + + )} + + + + ) +} diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts new file mode 100644 index 0000000000..b719d00b4e --- /dev/null +++ b/src/components/Post/Embed/types.ts @@ -0,0 +1,25 @@ +import {type StyleProp, type ViewStyle} from 'react-native' +import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api' + +export enum PostEmbedViewContext { + ThreadHighlighted = 'ThreadHighlighted', + Feed = 'Feed', + FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', +} + +export enum QuoteEmbedViewContext { + FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia, +} + +export type CommonProps = { + moderation?: ModerationDecision + onOpen?: () => void + style?: StyleProp + viewContext?: PostEmbedViewContext + isWithinQuote?: boolean + allowNestedQuotes?: boolean +} + +export type EmbedProps = CommonProps & { + embed?: AppBskyFeedDefs.PostView['embed'] +} diff --git a/src/components/Post/ShowMoreTextButton.tsx b/src/components/Post/ShowMoreTextButton.tsx new file mode 100644 index 0000000000..bc6db55b9e --- /dev/null +++ b/src/components/Post/ShowMoreTextButton.tsx @@ -0,0 +1,56 @@ +import {useCallback, useMemo} from 'react' +import {LayoutAnimation, type TextStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function ShowMoreTextButton({ + onPress: onPressProp, + style, +}: TextStyleProp & {onPress: () => void}) { + const t = useTheme() + const {_} = useLingui() + + const onPress = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + onPressProp() + }, [onPressProp]) + + const textStyle = useMemo(() => { + return flatten([a.leading_snug, a.text_sm, style]) as TextStyle & { + fontSize: number + lineHeight: number + } + }, [style]) + + return ( + + ) +} diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx index db63a7383a..31438c6bd3 100644 --- a/src/components/PostControls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -40,6 +40,17 @@ let RepostButton = ({ const requireAuth = useRequireAuth() const dialogControl = Dialog.useDialogControl() + const onPress = () => requireAuth(() => dialogControl.open()) + + const onLongPress = () => + requireAuth(() => { + if (embeddingDisabled) { + dialogControl.open() + } else { + onQuote() + } + }) + return ( <> requireAuth(() => dialogControl.open())} - onLongPress={() => requireAuth(() => onQuote())} + onPress={onPress} + onLongPress={onLongPress} label={ isReposted ? _( diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 17148a1b36..eb6efa4c97 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -74,7 +74,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) { return props.children } else { return ( - + ) diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts index 37087dc95a..7d9e19ac5b 100644 --- a/src/components/ProfileHoverCard/types.ts +++ b/src/components/ProfileHoverCard/types.ts @@ -1,8 +1,9 @@ import type React from 'react' -export type ProfileHoverCardProps = { - children: React.ReactElement +import {type ViewStyleProp} from '#/alf' + +export type ProfileHoverCardProps = ViewStyleProp & { + children: React.ReactNode did: string - inline?: boolean disable?: boolean } diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index d501f4287b..6493e23421 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,14 +1,14 @@ import React from 'react' -import {TextStyle} from 'react-native' +import {type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {toShortUrl} from '#/lib/strings/url-helpers' -import {atoms as a, flatten, TextStyleProp} from '#/alf' +import {atoms as a, flatten, type TextStyleProp} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' -import {InlineLinkText, LinkProps} from '#/components/Link' +import {InlineLinkText, type LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichTextTag} from '#/components/RichTextTag' -import {Text, TextProps} from '#/components/Typography' +import {Text, type TextProps} from '#/components/Typography' const WORD_WRAP = {wordWrap: 1} @@ -105,7 +105,7 @@ export function RichText({ !disableLinks ) { els.push( - + + + + ) +} + +export function Circle({ + children, + size, + blend, + style, +}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function Pill({ + size, + blend, + style, +}: ViewStyleProp & {size: number} & SkeletonProps) { + const t = useTheme() + return ( + + ) +} + +export function Col({ + children, + style, +}: ViewStyleProp & {children?: React.ReactNode}) { + return {children} +} + +export function Row({ + children, + style, +}: ViewStyleProp & {children?: React.ReactNode}) { + return {children} +} diff --git a/src/components/dialogs/ChangeEmailDialog.tsx b/src/components/dialogs/ChangeEmailDialog.tsx deleted file mode 100644 index 93397bae93..0000000000 --- a/src/components/dialogs/ChangeEmailDialog.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import {useState} from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {cleanError} from '#/lib/strings/errors' -import {useAgent, useSession} from '#/state/session' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {atoms as a, useBreakpoints, web} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import * as TextField from '#/components/forms/TextField' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' - -export function ChangeEmailDialog({ - control, - verifyEmailControl, -}: { - control: Dialog.DialogControlProps - verifyEmailControl: Dialog.DialogControlProps -}) { - return ( - - - - - ) -} - -export function Inner({ - verifyEmailControl, -}: { - verifyEmailControl: Dialog.DialogControlProps -}) { - const {_} = useLingui() - const {currentAccount} = useSession() - const agent = useAgent() - const control = Dialog.useDialogContext() - const {gtMobile} = useBreakpoints() - - const [currentStep, setCurrentStep] = useState< - 'StepOne' | 'StepTwo' | 'StepThree' - >('StepOne') - const [email, setEmail] = useState('') - const [confirmationCode, setConfirmationCode] = useState('') - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState('') - - const currentEmail = currentAccount?.email || '(no email)' - const uiStrings = { - StepOne: { - title: _(msg`Change Your Email`), - message: '', - }, - StepTwo: { - title: _(msg`Security Step Required`), - message: _( - msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`, - ), - }, - StepThree: { - title: _(msg`Email Updated!`), - message: _( - msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`, - ), - }, - } - - const onRequestChange = async () => { - if (email === currentAccount?.email) { - setError( - _( - msg`The email address you entered is the same as your current email address.`, - ), - ) - return - } - setError('') - setIsProcessing(true) - try { - const res = await agent.com.atproto.server.requestEmailUpdate() - if (res.data.tokenRequired) { - setCurrentStep('StepTwo') - } else { - await agent.com.atproto.server.updateEmail({email: email.trim()}) - await agent.resumeSession(agent.session!) - setCurrentStep('StepThree') - } - } catch (e) { - setError(cleanError(String(e))) - } finally { - setIsProcessing(false) - } - } - - const onConfirm = async () => { - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.updateEmail({ - email: email.trim(), - token: confirmationCode.trim(), - }) - await agent.resumeSession(agent.session!) - setCurrentStep('StepThree') - } catch (e) { - setError(cleanError(String(e))) - } finally { - setIsProcessing(false) - } - } - - const onVerify = async () => { - control.close(() => { - verifyEmailControl.open() - }) - } - - return ( - - - - - - {uiStrings[currentStep].title} - - {error ? ( - - - - ) : null} - {currentStep === 'StepOne' ? ( - - - Enter your new email address below. - - - - - - ) : ( - - {uiStrings[currentStep].message} - - )} - - {currentStep === 'StepTwo' ? ( - - - Confirmation code - - - - - - ) : null} - - {currentStep === 'StepOne' ? ( - <> - - - - ) : currentStep === 'StepTwo' ? ( - <> - - - - ) : currentStep === 'StepThree' ? ( - <> - - - - ) : null} - - - - ) -} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index 728044325b..1ee4d27398 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -17,6 +17,11 @@ type ControlsContext = { signinDialogControl: Control inAppBrowserConsentControl: StatefulControl emailDialogControl: StatefulControl + linkWarningDialogControl: StatefulControl<{ + href: string + displayText: string + share?: boolean + }> } const ControlsContext = createContext(null) @@ -36,6 +41,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const signinDialogControl = Dialog.useDialogControl() const inAppBrowserConsentControl = useStatefulDialogControl() const emailDialogControl = useStatefulDialogControl() + const linkWarningDialogControl = useStatefulDialogControl<{ + href: string + displayText: string + share?: boolean + }>() const ctx = useMemo( () => ({ @@ -43,12 +53,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { signinDialogControl, inAppBrowserConsentControl, emailDialogControl, + linkWarningDialogControl, }), [ mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl, emailDialogControl, + linkWarningDialogControl, ], ) diff --git a/src/components/dialogs/LinkWarning.tsx b/src/components/dialogs/LinkWarning.tsx new file mode 100644 index 0000000000..9ae8718127 --- /dev/null +++ b/src/components/dialogs/LinkWarning.tsx @@ -0,0 +1,161 @@ +import {useCallback, useMemo} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {shareUrl} from '#/lib/sharing' +import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {useGlobalDialogsControlContext} from './Context' + +export function LinkWarningDialog() { + const {linkWarningDialogControl} = useGlobalDialogsControlContext() + + return ( + + + + + ) +} + +function InAppBrowserConsentInner({ + link, +}: { + link?: {href: string; displayText: string; share?: boolean} +}) { + const control = Dialog.useDialogContext() + const {_} = useLingui() + const t = useTheme() + const openLink = useOpenLink() + const {gtMobile} = useBreakpoints() + + const potentiallyMisleading = useMemo( + () => link && isPossiblyAUrl(link.displayText), + [link], + ) + + const onPressVisit = useCallback(() => { + control.close(() => { + if (!link) return + if (link.share) { + shareUrl(link.href) + } else { + openLink(link.href, undefined, true) + } + }) + }, [control, link, openLink]) + + const onCancel = useCallback(() => { + control.close() + }, [control]) + + return ( + + + + + {potentiallyMisleading ? ( + Potentially misleading link + ) : ( + Leaving Bluesky + )} + + + This link is taking you to the following website: + + {link && } + {potentiallyMisleading && ( + + Make sure this is where you intend to go! + + )} + + + + + + + + + ) +} + +function LinkBox({href}: {href: string}) { + const t = useTheme() + const [scheme, hostname, rest] = useMemo(() => { + try { + const urlp = new URL(href) + const [subdomain, apexdomain] = splitApexDomain(urlp.hostname) + return [ + urlp.protocol + '//' + subdomain, + apexdomain, + urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash, + ] + } catch { + return ['', href, ''] + } + }, [href]) + return ( + + + {scheme} + + {hostname} + + {rest} + + + ) +} diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 26e20db57c..81655be0f5 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -397,6 +397,7 @@ function DefaultProfileCard({ void - onCloseAfterVerifying?: () => void - reasonText?: string - /** - * if a changeEmailControl for a ChangeEmailDialog is not provided, - * this component will create one for you. Using this prop - * helps reduce duplication, since these dialogs are often used together. - */ - changeEmailControl?: Dialog.DialogControlProps - reminder?: boolean -}) { - const agent = useAgent() - const fallbackChangeEmailControl = Dialog.useDialogControl() - - const [didVerify, setDidVerify] = useState(false) - - return ( - <> - { - if (!didVerify) { - onCloseWithoutVerifying?.() - return - } - - try { - await agent.resumeSession(agent.session!) - onCloseAfterVerifying?.() - } catch (e: unknown) { - logger.error(String(e)) - return - } - }}> - - - - {!changeEmailControl && ( - - )} - - ) -} - -export function Inner({ - setDidVerify, - reasonText, - changeEmailControl, - reminder, -}: { - setDidVerify: (value: boolean) => void - reasonText?: string - changeEmailControl: Dialog.DialogControlProps - reminder?: boolean -}) { - const control = Dialog.useDialogContext() - const {_} = useLingui() - const {currentAccount} = useSession() - const agent = useAgent() - const {gtMobile} = useBreakpoints() - const t = useTheme() - - const [currentStep, setCurrentStep] = useState< - 'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree' - >(reminder ? 'Reminder' : 'StepOne') - const [confirmationCode, setConfirmationCode] = useState('') - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState('') - - const uiStrings = { - Reminder: { - title: _(msg`Please Verify Your Email`), - message: _( - msg`Your email has not yet been verified. This is an important security step which we recommend.`, - ), - }, - StepOne: { - title: _(msg`Verify Your Email`), - message: '', - }, - StepTwo: { - title: _(msg`Enter Code`), - message: _( - msg`An email has been sent! Please enter the confirmation code included in the email below.`, - ), - }, - StepThree: { - title: _(msg`Success!`), - message: _(msg`Thank you! Your email has been successfully verified.`), - }, - } - - const onSendEmail = async () => { - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.requestEmailConfirmation() - setCurrentStep('StepTwo') - } catch (e: unknown) { - setError(cleanError(e)) - } finally { - setIsProcessing(false) - } - } - - const onVerifyEmail = async () => { - setError('') - setIsProcessing(true) - try { - await agent.com.atproto.server.confirmEmail({ - email: (currentAccount?.email || '').trim(), - token: confirmationCode.trim(), - }) - } catch (e: unknown) { - setError(cleanError(String(e))) - setIsProcessing(false) - return - } - - setIsProcessing(false) - setDidVerify(true) - setCurrentStep('StepThree') - } - - return ( - - - {currentStep === 'Reminder' && ( - - - - )} - - - {uiStrings[currentStep].title} - - {error ? ( - - - - ) : null} - {currentStep === 'StepOne' ? ( - - {reasonText ? ( - - {reasonText} - - Don't have access to{' '} - - {currentAccount?.email} - - ?{' '} - { - e.preventDefault() - control.close(() => { - changeEmailControl.open() - }) - return false - }}> - Change your email address - - . - - - ) : ( - - - You'll receive an email at{' '} - - {currentAccount?.email} - {' '} - to verify it's you. - {' '} - { - e.preventDefault() - control.close(() => { - changeEmailControl.open() - }) - return false - }}> - Need to change it? - - - )} - - ) : ( - - {uiStrings[currentStep].message} - - )} - - {currentStep === 'StepTwo' ? ( - - - Confirmation Code - - - - - - ) : null} - - {currentStep === 'Reminder' ? ( - <> - - - - ) : currentStep === 'StepOne' ? ( - <> - - - - ) : currentStep === 'StepTwo' ? ( - <> - - - - ) : currentStep === 'StepThree' ? ( - - ) : null} - - - - ) -} diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 120a5f8ad9..eb9f0a09a4 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -1,5 +1,5 @@ import {View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyConvoDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index f1c6189d06..6390300c1a 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -1,15 +1,16 @@ import React from 'react' import {useWindowDimensions, View} from 'react-native' -import {AppBskyEmbedRecord} from '@atproto/api' +import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' -import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {atoms as a, native, tokens, useTheme, web} from '#/alf' +import {PostEmbedViewContext} from '#/components/Post/Embed' +import {Embed} from '#/components/Post/Embed' import {MessageContextProvider} from './MessageContext' let MessageItemEmbed = ({ embed, }: { - embed: AppBskyEmbedRecord.View + embed: $Typed }): React.ReactNode => { const t = useTheme() const screen = useWindowDimensions() @@ -32,7 +33,7 @@ let MessageItemEmbed = ({ }), ]}> - ( retries: number, - cond: (err: any) => boolean, - fn: () => Promise

, + shouldRetry: (err: any) => boolean, + action: () => Promise

, + delay?: number, ): Promise

{ let lastErr while (retries > 0) { try { - return await fn() + return await action() } catch (e: any) { lastErr = e - if (cond(e)) { + if (shouldRetry(e)) { + if (delay) { + await timeout(delay) + } retries-- continue } diff --git a/src/lib/hooks/useCallOnce.ts b/src/lib/hooks/useCallOnce.ts new file mode 100644 index 0000000000..fa01cf4aa3 --- /dev/null +++ b/src/lib/hooks/useCallOnce.ts @@ -0,0 +1,20 @@ +import {useCallback} from 'react' + +export enum OnceKey { + PreferencesThread = 'preferences:thread', +} + +const called: Record = { + [OnceKey.PreferencesThread]: false, +} + +export function useCallOnce(key: OnceKey) { + return useCallback( + (cb: () => void) => { + if (called[key] === true) return + called[key] = true + cb() + }, + [key], + ) +} diff --git a/src/lib/hooks/useHandleRef.ts b/src/lib/hooks/useHandleRef.ts deleted file mode 100644 index 167ba270b6..0000000000 --- a/src/lib/hooks/useHandleRef.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {useState} from 'react' -import {AnimatedRef, measure, MeasuredDimensions} from 'react-native-reanimated' - -export type HandleRef = { - (node: any): void - current: null | number -} - -// This is a lighterweight alternative to `useAnimatedRef()` for imperative UI thread actions. -// Render it like , then pass `ref.current` to `measureHandle()` and such. -export function useHandleRef(): HandleRef { - return useState(() => { - const ref = (node: any) => { - if (node) { - ref.current = - node._nativeTag ?? - node.__nativeTag ?? - node.canonical?.nativeTag ?? - null - } else { - ref.current = null - } - } - ref.current = null - return ref - })[0] as HandleRef -} - -// When using this version, you need to read ref.current on the JS thread, and pass it to UI. -export function measureHandle( - current: number | null, -): MeasuredDimensions | null { - 'worklet' - if (current !== null) { - return measure((() => current) as AnimatedRef) - } else { - return null - } -} diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx new file mode 100644 index 0000000000..e21184fda4 --- /dev/null +++ b/src/lib/hooks/useHideBottomBarBorder.tsx @@ -0,0 +1,50 @@ +import {createContext, useCallback, useContext, useState} from 'react' +import {useFocusEffect} from '@react-navigation/native' + +type HideBottomBarBorderSetter = () => () => void + +const HideBottomBarBorderContext = createContext(false) +const HideBottomBarBorderSetterContext = + createContext(null) + +export function useHideBottomBarBorderSetter() { + const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext) + if (!hideBottomBarBorder) { + throw new Error( + 'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider', + ) + } + return hideBottomBarBorder +} + +export function useHideBottomBarBorderForScreen() { + const hideBorder = useHideBottomBarBorderSetter() + + useFocusEffect( + useCallback(() => { + const cleanup = hideBorder() + return () => cleanup() + }, [hideBorder]), + ) +} + +export function useHideBottomBarBorder() { + return useContext(HideBottomBarBorderContext) +} + +export function Provider({children}: {children: React.ReactNode}) { + const [refCount, setRefCount] = useState(0) + + const setter = useCallback(() => { + setRefCount(prev => prev + 1) + return () => setRefCount(prev => prev - 1) + }, []) + + return ( + + 0}> + {children} + + + ) +} diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts index dc18742c02..136e5fb961 100644 --- a/src/lib/hooks/useNavigationDeduped.ts +++ b/src/lib/hooks/useNavigationDeduped.ts @@ -14,6 +14,7 @@ export type DebouncedNavigationProp = Pick< | 'dispatch' | 'goBack' | 'getState' + | 'getParent' > export function useNavigationDeduped() { @@ -46,6 +47,9 @@ export function useNavigationDeduped() { getState: () => { return navigation.getState() }, + getParent: (...args: Parameters) => { + return navigation.getParent(...args) + }, }), [dedupe, navigation], ) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 62cbc55ac5..c7a429a243 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,8 +1,9 @@ -import {Image as RNImage, Share as RNShare} from 'react-native' +import {Image as RNImage} from 'react-native' import uuid from 'react-native-uuid' import { cacheDirectory, copyAsync, + createDownloadResumable, deleteAsync, EncodingType, getInfoAsync, @@ -14,7 +15,6 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' import {Buffer} from 'buffer' -import RNFetchBlob from 'rn-fetch-blob' import {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' @@ -68,28 +68,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { return } - let downloadRes + const path = createPath(appendExt) + try { - const downloadResPromise = RNFetchBlob.config({ - fileCache: true, - appendExt, - }).fetch('GET', opts.uri) - const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout) - downloadRes = await downloadResPromise - clearTimeout(to1) - - const status = downloadRes.info().status - if (status !== 200) { - return - } - - const localUri = normalizePath(downloadRes.path(), true) - return await doResize(localUri, opts) + await downloadImage(opts.uri, path, opts.timeout) + return await doResize(path, opts) } finally { - // TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey - if (downloadRes) { - downloadRes.flush() - } + safeDeleteAsync(path) } } @@ -98,32 +83,16 @@ export async function shareImageModal({uri}: {uri: string}) { // TODO might need to give an error to the user in this case -prf return } - const downloadResponse = await RNFetchBlob.config({ - fileCache: true, - }).fetch('GET', uri) - // NOTE - // assuming PNG // we're currently relying on the fact our CDN only serves pngs // -prf - - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) - - // NOTE - // for some reason expo-sharing refuses to work on iOS - // ...and visa versa - // -prf - if (isIOS) { - await RNShare.share({url: imagePath}) - } else { - await Sharing.shareAsync(imagePath, { - mimeType: 'image/png', - UTI: 'image/png', - }) - } - - safeDeleteAsync(imagePath) + const imageUri = await downloadImage(uri, createPath('png'), 5e3) + const imagePath = await moveToPermanentPath(imageUri, '.png') + safeDeleteAsync(imageUri) + await Sharing.shareAsync(imagePath, { + mimeType: 'image/png', + UTI: 'image/png', + }) } const ALBUM_NAME = 'Bluesky' @@ -134,11 +103,8 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) { // assuming PNG // we're currently relying on the fact our CDN only serves pngs // -prf - const downloadResponse = await RNFetchBlob.config({ - fileCache: true, - }).fetch('GET', uri) - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) + const imageUri = await downloadImage(uri, createPath('png'), 5e3) + const imagePath = await moveToPermanentPath(imageUri, '.png') // save try { @@ -403,3 +369,24 @@ export function getResizedDimensions(originalDims: { height: Math.round(originalDims.height * ratio), } } + +function createPath(ext: string) { + // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. + // we use a web-only function for downloadAndResize on web + return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` +} + +async function downloadImage(uri: string, path: string, timeout: number) { + const dlResumable = createDownloadResumable(uri, path, {cache: true}) + + const to1 = setTimeout(() => dlResumable.cancelAsync(), timeout) + + const dlRes = await dlResumable.downloadAsync() + clearTimeout(to1) + + if (!dlRes?.uri) { + throw new Error('Failed to download image - dlRes is undefined') + } + + return normalizePath(dlRes.uri) +} diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts index 45f9c85fdb..c74192f298 100644 --- a/src/lib/routes/router.ts +++ b/src/lib/routes/router.ts @@ -1,8 +1,8 @@ -import {Route, RouteParams} from './types' +import {type Route, type RouteParams} from './types' -export class Router { +export class Router> { routes: [string, Route][] = [] - constructor(description: Record) { + constructor(description: Record) { for (const [screen, pattern] of Object.entries(description)) { if (typeof pattern === 'string') { this.routes.push([screen, createRoute(pattern)]) @@ -14,7 +14,7 @@ export class Router { } } - matchName(name: string): Route | undefined { + matchName(name: keyof T | (string & {})): Route | undefined { for (const [screenName, route] of this.routes) { if (screenName === name) { return route @@ -45,7 +45,7 @@ function createRoute(pattern: string): Route { }) const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i') return { - match(path: string) { + match(path) { const {pathname, searchParams} = new URL(path, 'http://throwaway.com') const addedParams = Object.fromEntries(searchParams.entries()) @@ -55,10 +55,10 @@ function createRoute(pattern: string): Route { } return undefined }, - build(params: Record) { + build(params = {}) { const str = pattern.replace( /:([\w]+)/g, - (_m, name) => params[name] || 'undefined', + (_m, name) => params[encodeURIComponent(name)] || 'undefined', ) let hasQp = false diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 6f102d438a..c92be34c23 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -52,7 +52,18 @@ export type CommonNavigatorParams = { AccountSettings: undefined PrivacyAndSecuritySettings: undefined ContentAndMediaSettings: undefined - SettingsInterests: undefined + NotificationSettings: undefined + ReplyNotificationSettings: undefined + MentionNotificationSettings: undefined + QuoteNotificationSettings: undefined + LikeNotificationSettings: undefined + RepostNotificationSettings: undefined + NewFollowerNotificationSettings: undefined + LikesOnRepostsNotificationSettings: undefined + RepostsOnRepostsNotificationSettings: undefined + ActivityNotificationSettings: undefined + MiscellaneousNotificationSettings: undefined + InterestsSettings: undefined AboutSettings: undefined AppIconSettings: undefined Search: {q?: string} @@ -61,7 +72,7 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesSettings: undefined MessagesInbox: undefined - NotificationSettings: undefined + LegacyNotificationSettings: undefined Feeds: undefined Start: {name: string; rkey: string} StarterPack: {name: string; rkey: string; new?: boolean} @@ -104,8 +115,6 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Search: {q?: string} Feeds: undefined Notifications: undefined - Hashtag: {tag: string; author?: string} - Topic: {topic: string} Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} } @@ -118,15 +127,8 @@ export type AllNavigatorParams = CommonNavigatorParams & { NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined - Hashtag: {tag: string; author?: string} - Topic: {topic: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} - Start: {name: string; rkey: string} - StarterPack: {name: string; rkey: string; new?: boolean} - StarterPackShort: {code: string} - StarterPackWizard: undefined - StarterPackEdit: {rkey?: string} } // NOTE @@ -143,5 +145,5 @@ export type RouteParams = Record export type MatchResult = {params: RouteParams} export type Route = { match: (path: string) => MatchResult | undefined - build: (params: RouteParams) => string + build: (params?: Record) => string } diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c67bb60a3a..fca3f609af 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -6,6 +6,8 @@ export type Gate = | 'explore_show_suggested_feeds' | 'old_postonboarding' | 'onboarding_add_video_feed' + | 'post_threads_v2_unspecced' + | 'reengagement_features' | 'remove_show_latest_button' | 'test_gate_1' | 'test_gate_2' diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index ad194714a3..288f428c10 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -193,6 +193,11 @@ export function convertBskyAppUrlIfNeeded(url: string): string { return startUriToStarterPackUri(urlp.pathname) } + // special-case search links + if (urlp.pathname === '/search') { + return `/search?q=${urlp.searchParams.get('q')}` + } + return urlp.pathname } catch (e) { console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e) diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index 8d650a234c..380f996b91 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -1,4 +1,4 @@ -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import * as bcp47Match from 'bcp-47-match' import lande from 'lande' diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 7f3d35dfce..e668726899 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -74,8 +74,8 @@ msgstr "" msgid "{0, plural, one {# second} other {# seconds}}" msgstr "" -#: src/view/shell/bottom-bar/BottomBar.tsx:223 -#: src/view/shell/bottom-bar/BottomBar.tsx:255 +#: src/view/shell/bottom-bar/BottomBar.tsx:225 +#: src/view/shell/bottom-bar/BottomBar.tsx:257 #: src/view/shell/Drawer.tsx:487 msgid "{0, plural, one {# unread item} other {# unread items}}" msgstr "" @@ -95,7 +95,8 @@ msgstr "" msgid "{0, plural, one {following} other {following}}" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:529 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:474 +#: src/view/com/post-thread/PostThreadItem.tsx:541 msgid "{0, plural, one {like} other {likes}}" msgstr "" @@ -103,11 +104,13 @@ msgstr "" msgid "{0, plural, one {post} other {posts}}" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:513 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:458 +#: src/view/com/post-thread/PostThreadItem.tsx:525 msgid "{0, plural, one {quote} other {quotes}}" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:495 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:440 +#: src/view/com/post-thread/PostThreadItem.tsx:507 msgid "{0, plural, one {repost} other {reposts}}" msgstr "" @@ -142,7 +145,7 @@ msgstr "" msgid "{0} joined this week" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx:201 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:202 msgid "{0} of {1}" msgstr "" @@ -209,155 +212,155 @@ msgstr "" msgid "{estimatedTimeMins, plural, one {minute} other {minutes}}" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:335 +#: src/view/com/notifications/NotificationFeedItem.tsx:336 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:361 +#: src/view/com/notifications/NotificationFeedItem.tsx:362 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:254 +#: src/view/com/notifications/NotificationFeedItem.tsx:255 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:466 +#: src/view/com/notifications/NotificationFeedItem.tsx:467 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:439 +#: src/view/com/notifications/NotificationFeedItem.tsx:440 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:278 +#: src/view/com/notifications/NotificationFeedItem.tsx:279 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:490 +#: src/view/com/notifications/NotificationFeedItem.tsx:491 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:385 +#: src/view/com/notifications/NotificationFeedItem.tsx:386 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:414 +#: src/view/com/notifications/NotificationFeedItem.tsx:415 msgid "{firstAuthorLink} and <0>{additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:347 +#: src/view/com/notifications/NotificationFeedItem.tsx:348 msgid "{firstAuthorLink} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:324 +#: src/view/com/notifications/NotificationFeedItem.tsx:325 msgid "{firstAuthorLink} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:373 +#: src/view/com/notifications/NotificationFeedItem.tsx:374 msgid "{firstAuthorLink} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:266 +#: src/view/com/notifications/NotificationFeedItem.tsx:267 msgid "{firstAuthorLink} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:478 +#: src/view/com/notifications/NotificationFeedItem.tsx:479 msgid "{firstAuthorLink} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:451 +#: src/view/com/notifications/NotificationFeedItem.tsx:452 msgid "{firstAuthorLink} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:290 +#: src/view/com/notifications/NotificationFeedItem.tsx:291 msgid "{firstAuthorLink} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:502 +#: src/view/com/notifications/NotificationFeedItem.tsx:503 msgid "{firstAuthorLink} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:397 +#: src/view/com/notifications/NotificationFeedItem.tsx:398 msgid "{firstAuthorLink} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:426 +#: src/view/com/notifications/NotificationFeedItem.tsx:427 msgid "{firstAuthorLink} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:328 +#: src/view/com/notifications/NotificationFeedItem.tsx:329 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:354 +#: src/view/com/notifications/NotificationFeedItem.tsx:355 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:247 +#: src/view/com/notifications/NotificationFeedItem.tsx:248 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:459 +#: src/view/com/notifications/NotificationFeedItem.tsx:460 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:432 +#: src/view/com/notifications/NotificationFeedItem.tsx:433 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} removed their verifications from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:271 +#: src/view/com/notifications/NotificationFeedItem.tsx:272 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:483 +#: src/view/com/notifications/NotificationFeedItem.tsx:484 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:378 +#: src/view/com/notifications/NotificationFeedItem.tsx:379 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:407 +#: src/view/com/notifications/NotificationFeedItem.tsx:408 msgid "{firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}} verified you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:333 +#: src/view/com/notifications/NotificationFeedItem.tsx:334 msgid "{firstAuthorName} followed you" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:323 +#: src/view/com/notifications/NotificationFeedItem.tsx:324 msgid "{firstAuthorName} followed you back" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:359 +#: src/view/com/notifications/NotificationFeedItem.tsx:360 msgid "{firstAuthorName} liked your custom feed" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:252 +#: src/view/com/notifications/NotificationFeedItem.tsx:253 msgid "{firstAuthorName} liked your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:464 +#: src/view/com/notifications/NotificationFeedItem.tsx:465 msgid "{firstAuthorName} liked your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:437 +#: src/view/com/notifications/NotificationFeedItem.tsx:438 msgid "{firstAuthorName} removed their verification from your account" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:276 +#: src/view/com/notifications/NotificationFeedItem.tsx:277 msgid "{firstAuthorName} reposted your post" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:488 +#: src/view/com/notifications/NotificationFeedItem.tsx:489 msgid "{firstAuthorName} reposted your repost" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:383 +#: src/view/com/notifications/NotificationFeedItem.tsx:384 msgid "{firstAuthorName} signed up with your starter pack" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:412 +#: src/view/com/notifications/NotificationFeedItem.tsx:413 msgid "{firstAuthorName} verified you" msgstr "" @@ -366,7 +369,7 @@ msgstr "" msgid "{following} following" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:412 +#: src/components/dialogs/SearchablePeopleList.tsx:413 msgid "{handle} can't be messaged" msgstr "" @@ -387,7 +390,7 @@ msgstr "" msgid "{minutes, plural, one {# minute} other {# minutes}}" msgstr "" -#: src/view/shell/bottom-bar/BottomBarWeb.tsx:270 +#: src/view/shell/bottom-bar/BottomBarWeb.tsx:277 msgid "{notificationCount, plural, one {# unread item} other {# unread items}}" msgstr "" @@ -461,10 +464,6 @@ msgstr "" msgid "<0>{date} at {time}" msgstr "" -#: src/screens/Settings/NotificationSettings.tsx:85 -msgid "<0>Experimental: When this preference is enabled, you'll only receive reply and quote notifications from users you follow. We'll continue to add more controls here over time." -msgstr "" - #: src/screens/StarterPack/Wizard/index.tsx:440 msgid "<0>You and<1> <2>{0} are included in your starter pack" msgstr "" @@ -498,10 +497,10 @@ msgstr "" msgid "A new form of verification" msgstr "" -#: src/Navigation.tsx:403 +#: src/Navigation.tsx:490 #: src/screens/Settings/AboutSettings.tsx:75 -#: src/screens/Settings/Settings.tsx:225 -#: src/screens/Settings/Settings.tsx:228 +#: src/screens/Settings/Settings.tsx:234 +#: src/screens/Settings/Settings.tsx:237 msgid "About" msgstr "" @@ -520,20 +519,20 @@ msgid "Accept Request" msgstr "" #: src/screens/Settings/AccessibilitySettings.tsx:46 -#: src/screens/Settings/Settings.tsx:201 -#: src/screens/Settings/Settings.tsx:204 +#: src/screens/Settings/Settings.tsx:210 +#: src/screens/Settings/Settings.tsx:213 msgid "Accessibility" msgstr "" -#: src/Navigation.tsx:355 +#: src/Navigation.tsx:365 msgid "Accessibility Settings" msgstr "" -#: src/Navigation.tsx:371 +#: src/Navigation.tsx:381 #: src/screens/Login/LoginForm.tsx:194 #: src/screens/Settings/AccountSettings.tsx:48 -#: src/screens/Settings/Settings.tsx:163 -#: src/screens/Settings/Settings.tsx:166 +#: src/screens/Settings/Settings.tsx:164 +#: src/screens/Settings/Settings.tsx:167 msgid "Account" msgstr "" @@ -564,11 +563,11 @@ msgstr "" msgid "Account Muted by List" msgstr "" -#: src/screens/Settings/Settings.tsx:505 +#: src/screens/Settings/Settings.tsx:514 msgid "Account options" msgstr "" -#: src/screens/Settings/Settings.tsx:541 +#: src/screens/Settings/Settings.tsx:550 msgid "Account removed from quick access" msgstr "" @@ -640,14 +639,14 @@ msgstr "" msgid "Add alt text (optional)" msgstr "" -#: src/screens/Settings/Settings.tsx:445 -#: src/screens/Settings/Settings.tsx:448 +#: src/screens/Settings/Settings.tsx:454 +#: src/screens/Settings/Settings.tsx:457 #: src/view/shell/desktop/LeftNav.tsx:260 #: src/view/shell/desktop/LeftNav.tsx:264 msgid "Add another account" msgstr "" -#: src/view/com/composer/Composer.tsx:731 +#: src/view/com/composer/Composer.tsx:773 msgid "Add another post" msgstr "" @@ -678,7 +677,7 @@ msgstr "" msgid "Add muted words and tags" msgstr "" -#: src/view/com/composer/Composer.tsx:1287 +#: src/view/com/composer/Composer.tsx:1335 msgid "Add new post" msgstr "" @@ -769,7 +768,6 @@ msgstr "" msgid "Advanced" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:143 #: src/components/dialogs/EmailDialog/screens/Update.tsx:223 msgid "alice@example.com" msgstr "" @@ -823,9 +821,9 @@ msgstr "" msgid "Already signed in as @{0}" msgstr "" +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:186 #: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/photos/Gallery.tsx:187 -#: src/view/com/util/post-embeds/GifEmbed.tsx:186 msgid "ALT" msgstr "" @@ -839,7 +837,7 @@ msgstr "" msgid "Alt text" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:191 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:191 msgid "Alt Text" msgstr "" @@ -856,19 +854,11 @@ msgstr "" msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:59 -msgid "An email has been sent to your previous address, {currentEmail}. It includes a confirmation code which you can enter below." -msgstr "" - -#: src/components/dialogs/VerifyEmailDialog.tsx:120 -msgid "An email has been sent! Please enter the confirmation code included in the email below." -msgstr "" - #: src/components/dialogs/GifSelect.tsx:265 msgid "An error has occurred" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:420 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:420 msgid "An error occurred" msgstr "" @@ -884,11 +874,11 @@ msgstr "" msgid "An error occurred while generating your starter pack. Want to try again?" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbed.tsx:160 +#: src/components/Post/Embed/VideoEmbed/index.tsx:160 msgid "An error occurred while loading the video. Please try again later." msgstr "" -#: src/view/com/util/post-embeds/VideoEmbed.web.tsx:198 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:199 msgid "An error occurred while loading the video. Please try again." msgstr "" @@ -958,7 +948,7 @@ msgstr "" msgid "Animals" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:149 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:149 msgid "Animated GIF" msgstr "" @@ -975,7 +965,7 @@ msgstr "" msgid "Anybody can interact" msgstr "" -#: src/Navigation.tsx:411 +#: src/Navigation.tsx:498 #: src/screens/Settings/AppIconSettings/index.tsx:67 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:18 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:23 @@ -1012,7 +1002,7 @@ msgstr "" msgid "App passwords" msgstr "" -#: src/Navigation.tsx:323 +#: src/Navigation.tsx:333 #: src/screens/Settings/AppPasswords.tsx:51 msgid "App Passwords" msgstr "" @@ -1048,10 +1038,10 @@ msgstr "" msgid "Appeal this decision" msgstr "" -#: src/Navigation.tsx:363 +#: src/Navigation.tsx:373 #: src/screens/Settings/AppearanceSettings.tsx:85 -#: src/screens/Settings/Settings.tsx:193 -#: src/screens/Settings/Settings.tsx:196 +#: src/screens/Settings/Settings.tsx:202 +#: src/screens/Settings/Settings.tsx:205 msgid "Appearance" msgstr "" @@ -1060,12 +1050,15 @@ msgstr "" msgid "Apply default recommended feeds" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:945 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:640 +#: src/view/com/post-thread/PostThreadItem.tsx:955 msgid "Archived from {0}" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:914 -#: src/view/com/post-thread/PostThreadItem.tsx:953 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:609 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:648 +#: src/view/com/post-thread/PostThreadItem.tsx:924 +#: src/view/com/post-thread/PostThreadItem.tsx:963 msgid "Archived post" msgstr "" @@ -1101,11 +1094,11 @@ msgstr "" msgid "Are you sure you want to remove this from your feeds?" msgstr "" -#: src/view/com/composer/Composer.tsx:682 +#: src/view/com/composer/Composer.tsx:724 msgid "Are you sure you'd like to discard this draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:861 +#: src/view/com/composer/Composer.tsx:905 msgid "Are you sure you'd like to discard this post?" msgstr "" @@ -1257,7 +1250,7 @@ msgstr "" msgid "Block User" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:84 +#: src/components/Post/Embed/index.tsx:180 msgid "Blocked" msgstr "" @@ -1265,7 +1258,7 @@ msgstr "" msgid "Blocked accounts" msgstr "" -#: src/Navigation.tsx:164 +#: src/Navigation.tsx:174 #: src/view/screens/ModerationBlockedAccounts.tsx:104 msgid "Blocked Accounts" msgstr "" @@ -1279,7 +1272,7 @@ msgstr "" msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." msgstr "" -#: src/view/com/post-thread/PostThread.tsx:468 +#: src/view/com/post-thread/PostThread.tsx:488 msgid "Blocked post." msgstr "" @@ -1304,7 +1297,8 @@ msgstr "" msgid "Bluesky" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:970 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:665 +#: src/view/com/post-thread/PostThreadItem.tsx:980 msgid "Bluesky cannot confirm the authenticity of the claimed date." msgstr "" @@ -1363,23 +1357,23 @@ msgstr "" msgid "Books" msgstr "" -#: src/components/FeedInterstitials.tsx:373 +#: src/components/FeedInterstitials.tsx:379 msgid "Browse more accounts on the Explore page" msgstr "" -#: src/components/FeedInterstitials.tsx:511 +#: src/components/FeedInterstitials.tsx:517 msgid "Browse more feeds on the Explore page" msgstr "" -#: src/components/FeedInterstitials.tsx:353 -#: src/components/FeedInterstitials.tsx:356 -#: src/components/FeedInterstitials.tsx:492 -#: src/components/FeedInterstitials.tsx:495 +#: src/components/FeedInterstitials.tsx:359 +#: src/components/FeedInterstitials.tsx:362 +#: src/components/FeedInterstitials.tsx:498 +#: src/components/FeedInterstitials.tsx:501 msgid "Browse more suggestions" msgstr "" -#: src/components/FeedInterstitials.tsx:381 -#: src/components/FeedInterstitials.tsx:520 +#: src/components/FeedInterstitials.tsx:387 +#: src/components/FeedInterstitials.tsx:526 msgid "Browse more suggestions on the Explore page" msgstr "" @@ -1450,7 +1444,7 @@ msgstr "" #: src/components/live/GoLiveDialog.tsx:247 #: src/components/live/GoLiveDialog.tsx:253 #: src/components/Menu/index.tsx:350 -#: src/components/PostControls/RepostButton.tsx:198 +#: src/components/PostControls/RepostButton.tsx:209 #: src/components/Prompt.tsx:143 #: src/components/Prompt.tsx:145 #: src/screens/Deactivated.tsx:158 @@ -1461,18 +1455,17 @@ msgstr "" #: src/screens/Settings/AppIconSettings/index.tsx:225 #: src/screens/Settings/components/ChangeHandleDialog.tsx:78 #: src/screens/Settings/components/ChangeHandleDialog.tsx:85 -#: src/screens/Settings/Settings.tsx:270 +#: src/screens/Settings/Settings.tsx:279 #: src/screens/Takendown.tsx:99 #: src/screens/Takendown.tsx:102 -#: src/view/com/composer/Composer.tsx:916 -#: src/view/com/composer/Composer.tsx:927 +#: src/view/com/composer/Composer.tsx:960 +#: src/view/com/composer/Composer.tsx:971 #: src/view/com/composer/photos/EditImageDialog.web.tsx:43 #: src/view/com/composer/photos/EditImageDialog.web.tsx:52 #: src/view/com/modals/ChangePassword.tsx:279 #: src/view/com/modals/ChangePassword.tsx:282 #: src/view/com/modals/CreateOrEditList.tsx:333 #: src/view/com/modals/CropImage.web.tsx:97 -#: src/view/com/modals/EditProfile.tsx:269 #: src/view/com/modals/LinkWarning.tsx:105 #: src/view/com/modals/LinkWarning.tsx:107 #: src/view/shell/desktop/LeftNav.tsx:213 @@ -1495,11 +1488,7 @@ msgstr "" msgid "Cancel image crop" msgstr "" -#: src/view/com/modals/EditProfile.tsx:264 -msgid "Cancel profile editing" -msgstr "" - -#: src/components/PostControls/RepostButton.tsx:192 +#: src/components/PostControls/RepostButton.tsx:203 msgid "Cancel quote post" msgstr "" @@ -1518,7 +1507,7 @@ msgstr "" #: src/components/PostControls/index.tsx:101 #: src/components/PostControls/index.tsx:132 #: src/components/PostControls/index.tsx:160 -#: src/state/shell/composer/index.tsx:82 +#: src/state/shell/composer/index.tsx:91 msgid "Cannot interact with a blocked user" msgstr "" @@ -1548,11 +1537,6 @@ msgstr "" msgid "Change app language" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:200 -#: src/components/dialogs/VerifyEmailDialog.tsx:225 -msgid "Change email address" -msgstr "" - #: src/screens/Settings/components/ChangeHandleDialog.tsx:94 #: src/screens/Settings/components/ChangeHandleDialog.tsx:98 msgid "Change Handle" @@ -1574,14 +1558,6 @@ msgstr "" msgid "Change report reason" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:53 -msgid "Change Your Email" -msgstr "" - -#: src/components/dialogs/VerifyEmailDialog.tsx:209 -msgid "Change your email address" -msgstr "" - #: src/screens/Settings/AppIconSettings/index.tsx:216 msgid "Changes app icon" msgstr "" @@ -1591,8 +1567,8 @@ msgstr "" msgid "Changes hosting provider" msgstr "" -#: src/Navigation.tsx:428 -#: src/view/shell/bottom-bar/BottomBar.tsx:219 +#: src/Navigation.tsx:515 +#: src/view/shell/bottom-bar/BottomBar.tsx:221 #: src/view/shell/desktop/LeftNav.tsx:553 #: src/view/shell/Drawer.tsx:455 msgid "Chat" @@ -1609,7 +1585,7 @@ msgctxt "toast" msgid "Chat muted" msgstr "" -#: src/Navigation.tsx:438 +#: src/Navigation.tsx:525 #: src/screens/Messages/components/InboxPreview.tsx:24 msgid "Chat request inbox" msgstr "" @@ -1620,7 +1596,7 @@ msgid "Chat requests" msgstr "" #: src/components/dms/ConvoMenu.tsx:75 -#: src/Navigation.tsx:433 +#: src/Navigation.tsx:520 #: src/screens/Messages/ChatList.tsx:341 msgid "Chat settings" msgstr "" @@ -1692,11 +1668,11 @@ msgstr "" msgid "Choose your username" msgstr "" -#: src/screens/Settings/Settings.tsx:423 +#: src/screens/Settings/Settings.tsx:432 msgid "Clear all storage data" msgstr "" -#: src/screens/Settings/Settings.tsx:425 +#: src/screens/Settings/Settings.tsx:434 msgid "Clear all storage data (restart after this)" msgstr "" @@ -1745,14 +1721,10 @@ msgstr "" msgid "Clip 🐴 clop 🐴" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:244 -#: src/components/dialogs/ChangeEmailDialog.tsx:250 #: src/components/dialogs/GifSelect.tsx:281 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:187 #: src/components/dialogs/SearchablePeopleList.tsx:295 -#: src/components/dialogs/VerifyEmailDialog.tsx:346 -#: src/components/dialogs/VerifyEmailDialog.tsx:352 #: src/components/dms/EmojiPopup.android.tsx:58 #: src/components/dms/ReportDialog.tsx:381 #: src/components/dms/ReportDialog.tsx:390 @@ -1760,6 +1732,7 @@ msgstr "" #: src/components/live/EditLiveDialog.tsx:235 #: src/components/NewskieDialog.tsx:146 #: src/components/NewskieDialog.tsx:153 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:197 #: src/components/ProgressGuide/FollowDialog.tsx:386 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:117 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:123 @@ -1767,7 +1740,6 @@ msgstr "" #: src/components/verification/VerifierDialog.tsx:144 #: src/view/com/modals/ChangePassword.tsx:279 #: src/view/com/modals/ChangePassword.tsx:282 -#: src/view/com/util/post-embeds/GifEmbed.tsx:197 msgid "Close" msgstr "" @@ -1825,7 +1797,7 @@ msgstr "" msgid "Closes password update alert" msgstr "" -#: src/view/com/composer/Composer.tsx:924 +#: src/view/com/composer/Composer.tsx:968 msgid "Closes post composer and discards post draft" msgstr "" @@ -1838,11 +1810,11 @@ msgstr "" msgid "Closes viewer for header image" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:537 +#: src/view/com/notifications/NotificationFeedItem.tsx:538 msgid "Collapse list of users" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:748 +#: src/view/com/notifications/NotificationFeedItem.tsx:749 msgid "Collapses list of users for a given notification" msgstr "" @@ -1865,7 +1837,7 @@ msgstr "" msgid "Comics" msgstr "" -#: src/Navigation.tsx:313 +#: src/Navigation.tsx:323 #: src/view/screens/CommunityGuidelines.tsx:34 msgid "Community Guidelines" msgstr "" @@ -1882,15 +1854,15 @@ msgstr "" msgid "Compose new post" msgstr "" -#: src/view/com/composer/Composer.tsx:825 +#: src/view/com/composer/Composer.tsx:869 msgid "Compose posts up to {0, plural, other {# characters}} in length" msgstr "" -#: src/view/com/post-thread/PostThreadComposePrompt.tsx:34 +#: src/view/com/post-thread/PostThreadComposePrompt.tsx:62 msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:1669 +#: src/view/com/composer/Composer.tsx:1729 msgid "Compressing video..." msgstr "" @@ -1902,10 +1874,6 @@ msgstr "" msgid "Configured in <0>moderation settings." msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:203 -#: src/components/dialogs/ChangeEmailDialog.tsx:210 -#: src/components/dialogs/VerifyEmailDialog.tsx:316 -#: src/components/dialogs/VerifyEmailDialog.tsx:323 #: src/components/Prompt.tsx:186 #: src/components/Prompt.tsx:189 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:185 @@ -1929,10 +1897,7 @@ msgstr "" msgid "Confirm your birthdate" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:160 -#: src/components/dialogs/ChangeEmailDialog.tsx:164 #: src/components/dialogs/EmailDialog/components/TokenField.tsx:36 -#: src/components/dialogs/VerifyEmailDialog.tsx:252 #: src/screens/Login/LoginForm.tsx:274 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:144 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:150 @@ -1941,10 +1906,6 @@ msgstr "" msgid "Confirmation code" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:248 -msgid "Confirmation Code" -msgstr "" - #: src/screens/Login/LoginForm.tsx:337 msgid "Connecting..." msgstr "" @@ -1959,12 +1920,12 @@ msgid "Content & Media" msgstr "" #: src/screens/Settings/AccessibilitySettings.tsx:109 -#: src/screens/Settings/Settings.tsx:185 -#: src/screens/Settings/Settings.tsx:188 +#: src/screens/Settings/Settings.tsx:194 +#: src/screens/Settings/Settings.tsx:197 msgid "Content and media" msgstr "" -#: src/Navigation.tsx:387 +#: src/Navigation.tsx:474 msgid "Content and Media" msgstr "" @@ -2014,6 +1975,11 @@ msgstr "" msgid "Continue as {0} (currently signed in)" msgstr "" +#: src/screens/PostThread/components/ThreadItemReadMoreUp.tsx:27 +msgid "Continue thread" +msgstr "" + +#: src/screens/PostThread/components/ThreadItemReadMoreUp.tsx:63 #: src/view/com/post-thread/PostThreadLoadMore.tsx:60 msgid "Continue thread..." msgstr "" @@ -2150,7 +2116,7 @@ msgstr "" msgid "Copy TXT record value" msgstr "" -#: src/Navigation.tsx:318 +#: src/Navigation.tsx:328 #: src/view/screens/CopyrightPolicy.tsx:31 msgid "Copyright Policy" msgstr "" @@ -2176,6 +2142,10 @@ msgstr "" msgid "Could not process your video" msgstr "" +#: src/state/queries/notifications/settings.ts:47 +msgid "Could not update notification settings" +msgstr "" + #: src/components/StarterPack/ProfileStarterPacks.tsx:300 msgid "Create" msgstr "" @@ -2186,7 +2156,7 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:178 #: src/components/StarterPack/ProfileStarterPacks.tsx:287 -#: src/Navigation.tsx:463 +#: src/Navigation.tsx:550 msgid "Create a starter pack" msgstr "" @@ -2196,10 +2166,10 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:55 #: src/view/com/auth/SplashScreen.web.tsx:117 -#: src/view/shell/bottom-bar/BottomBar.tsx:343 -#: src/view/shell/bottom-bar/BottomBar.tsx:348 -#: src/view/shell/bottom-bar/BottomBarWeb.tsx:199 -#: src/view/shell/bottom-bar/BottomBarWeb.tsx:204 +#: src/view/shell/bottom-bar/BottomBar.tsx:345 +#: src/view/shell/bottom-bar/BottomBar.tsx:350 +#: src/view/shell/bottom-bar/BottomBarWeb.tsx:206 +#: src/view/shell/bottom-bar/BottomBarWeb.tsx:211 #: src/view/shell/NavSignupCard.tsx:47 #: src/view/shell/NavSignupCard.tsx:52 msgid "Create account" @@ -2297,7 +2267,7 @@ msgstr "" msgid "Deactivate account" msgstr "" -#: src/screens/Settings/Settings.tsx:397 +#: src/screens/Settings/Settings.tsx:406 msgid "Debug Moderation" msgstr "" @@ -2346,7 +2316,7 @@ msgstr "" msgid "Delete chat" msgstr "" -#: src/screens/Settings/Settings.tsx:404 +#: src/screens/Settings/Settings.tsx:413 msgid "Delete chat declaration record" msgstr "" @@ -2383,7 +2353,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:682 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:684 -#: src/view/com/composer/Composer.tsx:835 +#: src/view/com/composer/Composer.tsx:879 msgid "Delete post" msgstr "" @@ -2404,7 +2374,7 @@ msgstr "" msgid "Delete this post?" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:94 +#: src/components/Post/Embed/index.tsx:173 msgid "Deleted" msgstr "" @@ -2413,15 +2383,13 @@ msgstr "" msgid "Deleted Account" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:454 +#: src/view/com/post-thread/PostThread.tsx:474 msgid "Deleted post." msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:369 #: src/view/com/modals/CreateOrEditList.tsx:278 #: src/view/com/modals/CreateOrEditList.tsx:299 -#: src/view/com/modals/EditProfile.tsx:218 -#: src/view/com/modals/EditProfile.tsx:230 msgid "Description" msgstr "" @@ -2457,8 +2425,8 @@ msgctxt "toast" msgid "Developer mode enabled" msgstr "" -#: src/screens/Settings/Settings.tsx:252 -#: src/screens/Settings/Settings.tsx:255 +#: src/screens/Settings/Settings.tsx:261 +#: src/screens/Settings/Settings.tsx:264 msgid "Developer options" msgstr "" @@ -2488,7 +2456,7 @@ msgstr "" msgid "Disable haptic feedback" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:386 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:386 msgid "Disable subtitles" msgstr "" @@ -2502,8 +2470,8 @@ msgid "Disabled" msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:88 -#: src/view/com/composer/Composer.tsx:684 -#: src/view/com/composer/Composer.tsx:868 +#: src/view/com/composer/Composer.tsx:726 +#: src/view/com/composer/Composer.tsx:912 msgid "Discard" msgstr "" @@ -2511,11 +2479,11 @@ msgstr "" msgid "Discard changes?" msgstr "" -#: src/view/com/composer/Composer.tsx:681 +#: src/view/com/composer/Composer.tsx:723 msgid "Discard draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:860 +#: src/view/com/composer/Composer.tsx:904 msgid "Discard post?" msgstr "" @@ -2541,7 +2509,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: src/view/com/composer/Composer.tsx:1593 +#: src/view/com/composer/Composer.tsx:1653 msgid "Dismiss error" msgstr "" @@ -2565,14 +2533,9 @@ msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:320 #: src/screens/Profile/Header/EditProfileDialog.tsx:326 #: src/screens/Profile/Header/EditProfileDialog.tsx:376 -#: src/view/com/modals/EditProfile.tsx:194 msgid "Display name" msgstr "" -#: src/view/com/modals/EditProfile.tsx:182 -msgid "Display Name" -msgstr "" - #: src/screens/Profile/Header/EditProfileDialog.tsx:339 msgid "Display name is too long" msgstr "" @@ -2651,7 +2614,7 @@ msgstr "" msgid "Double tap to close the dialog" msgstr "" -#: src/screens/VideoFeed/index.tsx:1077 +#: src/screens/VideoFeed/index.tsx:1080 msgid "Double tap to like" msgstr "" @@ -2680,18 +2643,10 @@ msgstr "" msgid "e.g. Alice Lastname" msgstr "" -#: src/view/com/modals/EditProfile.tsx:187 -msgid "e.g. Alice Roberts" -msgstr "" - #: src/screens/Settings/components/ChangeHandleDialog.tsx:376 msgid "e.g. alice.com" msgstr "" -#: src/view/com/modals/EditProfile.tsx:223 -msgid "e.g. Artist, dog-lover, and avid reader." -msgstr "" - #: src/lib/moderation/useGlobalLabelStrings.ts:43 msgid "E.g. artistic nudes." msgstr "" @@ -2767,15 +2722,11 @@ msgstr "" msgid "Edit Moderation List" msgstr "" -#: src/Navigation.tsx:328 +#: src/Navigation.tsx:338 #: src/view/screens/Feeds.tsx:518 msgid "Edit My Feeds" msgstr "" -#: src/view/com/modals/EditProfile.tsx:154 -msgid "Edit my profile" -msgstr "" - #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:109 msgid "Edit People" msgstr "" @@ -2809,15 +2760,7 @@ msgstr "" msgid "Edit who can reply" msgstr "" -#: src/view/com/modals/EditProfile.tsx:195 -msgid "Edit your display name" -msgstr "" - -#: src/view/com/modals/EditProfile.tsx:231 -msgid "Edit your profile description" -msgstr "" - -#: src/Navigation.tsx:468 +#: src/Navigation.tsx:555 msgid "Edit your starter pack" msgstr "" @@ -2856,10 +2799,6 @@ msgstr "" msgid "Email sent!" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:63 -msgid "Email Updated!" -msgstr "" - #: src/components/dialogs/EmailDialog/screens/Verify.tsx:182 msgid "Email verification complete!" msgstr "" @@ -2883,7 +2822,7 @@ msgstr "" msgid "Embed this post in your website. Simply copy the following snippet and paste it into the HTML code of your website." msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx:58 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:58 msgid "Embedded video player" msgstr "" @@ -2914,12 +2853,12 @@ msgstr "" msgid "Enable media players for" msgstr "" -#: src/screens/Settings/NotificationSettings.tsx:74 -#: src/screens/Settings/NotificationSettings.tsx:77 -msgid "Enable priority notifications" +#: src/screens/Settings/NotificationSettings/index.tsx:102 +#: src/screens/Settings/NotificationSettings/index.tsx:106 +msgid "Enable push notifications" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:387 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:387 msgid "Enable subtitles" msgstr "" @@ -2968,11 +2907,7 @@ msgstr "" msgid "Enter code" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:118 -msgid "Enter Code" -msgstr "" - -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:405 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 msgid "Enter fullscreen" msgstr "" @@ -3001,10 +2936,6 @@ msgstr "" msgid "Enter your email address" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:138 -msgid "Enter your new email address below." -msgstr "" - #: src/screens/Login/LoginForm.tsx:243 msgid "Enter your password" msgstr "" @@ -3013,7 +2944,7 @@ msgstr "" msgid "Enter your username and password" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:135 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:135 msgid "Enters full screen" msgstr "" @@ -3021,11 +2952,15 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:1678 +#: src/view/com/composer/Composer.tsx:1738 #: src/view/com/util/error/ErrorScreen.tsx:42 msgid "Error" msgstr "" +#: src/screens/PostThread/components/ThreadError.tsx:26 +msgid "Error loading post" +msgstr "" + #: src/screens/Settings/components/ExportCarDialog.tsx:47 msgid "Error occurred while saving file" msgstr "" @@ -3056,9 +2991,16 @@ msgstr "" #: src/screens/Messages/Settings.tsx:83 #: src/screens/Messages/Settings.tsx:86 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:153 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:167 msgid "Everyone" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:236 +#: src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx:41 +msgid "Everything else" +msgstr "" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:73 #: src/lib/moderation/useReportOptions.ts:73 msgid "Excessive mentions or replies" @@ -3077,7 +3019,7 @@ msgstr "" msgid "Excludes users you follow" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:404 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:404 msgid "Exit fullscreen" msgstr "" @@ -3097,15 +3039,19 @@ msgstr "" msgid "Expand alt text" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:538 +#: src/view/com/notifications/NotificationFeedItem.tsx:539 msgid "Expand list of users" msgstr "" -#: src/view/com/composer/ComposerReplyTo.tsx:84 +#: src/view/com/composer/ComposerReplyTo.tsx:91 msgid "Expand or collapse the full post you are replying to" msgstr "" -#: src/screens/VideoFeed/index.tsx:962 +#: src/components/Post/ShowMoreTextButton.tsx:32 +msgid "Expand post text" +msgstr "" + +#: src/screens/VideoFeed/index.tsx:965 msgid "Expands or collapses post text" msgstr "" @@ -3114,7 +3060,7 @@ msgid "Expected uri to resolve to a record" msgstr "" #: src/screens/Settings/FollowingFeedPreferences.tsx:123 -#: src/screens/Settings/ThreadPreferences.tsx:137 +#: src/screens/Settings/ThreadPreferences.tsx:271 msgid "Experimental" msgstr "" @@ -3144,7 +3090,7 @@ msgstr "" msgid "Explicit sexual images." msgstr "" -#: src/Navigation.tsx:625 +#: src/Navigation.tsx:712 #: src/screens/Search/Shell.tsx:307 #: src/view/shell/desktop/LeftNav.tsx:635 #: src/view/shell/Drawer.tsx:403 @@ -3175,7 +3121,7 @@ msgstr "" msgid "External media may allow websites to collect information about you and your device. No information is sent or requested until you press the \"play\" button." msgstr "" -#: src/Navigation.tsx:347 +#: src/Navigation.tsx:357 #: src/screens/Settings/ExternalMediaPreferences.tsx:31 msgid "External Media Preferences" msgstr "" @@ -3245,6 +3191,19 @@ msgstr "" msgid "Failed to load GIFs" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:115 +#: src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx:50 +#: src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx:52 +#: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:50 +#: src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx:53 +#: src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx:50 +#: src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx:50 +#: src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx:52 +#: src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx:50 +#: src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx:53 +msgid "Failed to load notification settings." +msgstr "" + #: src/screens/Messages/components/MessageListError.tsx:23 msgid "Failed to load past messages" msgstr "" @@ -3282,15 +3241,11 @@ msgstr "" msgid "Failed to save image: {0}" msgstr "" -#: src/state/queries/notifications/settings.ts:39 -msgid "Failed to save notification preferences, please try again" -msgstr "" - #: src/screens/ModerationInteractionSettings/index.tsx:108 msgid "Failed to save settings. Please try again." msgstr "" -#: src/screens/Settings/SettingsInterests.tsx:133 +#: src/screens/Settings/InterestsSettings.tsx:136 msgctxt "toast" msgid "Failed to save your interests." msgstr "" @@ -3345,7 +3300,7 @@ msgstr "" msgid "Failed to verify handle. Please try again." msgstr "" -#: src/Navigation.tsx:263 +#: src/Navigation.tsx:273 msgid "Feed" msgstr "" @@ -3374,7 +3329,7 @@ msgctxt "toast" msgid "Feedback sent!" msgstr "" -#: src/Navigation.tsx:448 +#: src/Navigation.tsx:535 #: src/screens/Search/SearchResults.tsx:68 #: src/screens/StarterPack/StarterPackScreen.tsx:190 #: src/view/screens/Feeds.tsx:511 @@ -3535,7 +3490,7 @@ msgstr "" msgid "Followed by <0>{0}, <1>{1}, and {2, plural, one {# other} other {# others}}" msgstr "" -#: src/Navigation.tsx:217 +#: src/Navigation.tsx:227 msgid "Followers of @{0} that you know" msgstr "" @@ -3570,7 +3525,7 @@ msgstr "" msgid "Following feed preferences" msgstr "" -#: src/Navigation.tsx:334 +#: src/Navigation.tsx:344 #: src/screens/Settings/FollowingFeedPreferences.tsx:53 msgid "Following Feed Preferences" msgstr "" @@ -3638,7 +3593,7 @@ msgstr "" msgid "From @{sanitizedAuthor}" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:326 +#: src/view/com/posts/PostFeedItem.tsx:330 msgctxt "from-feed" msgid "From <0/>" msgstr "" @@ -3655,10 +3610,40 @@ msgstr "" msgid "Get help" msgstr "" +#: src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx:43 +msgid "Get notifications when people follow you." +msgstr "" + +#: src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx:43 +msgid "Get notifications when people like posts that you've reposted." +msgstr "" + +#: src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx:43 +msgid "Get notifications when people like your posts." +msgstr "" + +#: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:43 +msgid "Get notifications when people mention you." +msgstr "" + +#: src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx:43 +msgid "Get notifications when people quote your posts." +msgstr "" + +#: src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx:43 +msgid "Get notifications when people reply to your posts." +msgstr "" + +#: src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx:43 +msgid "Get notifications when people repost posts that you've reposted." +msgstr "" + +#: src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx:43 +msgid "Get notifications when people repost your posts." +msgstr "" + #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:76 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:86 -#: src/components/dialogs/VerifyEmailDialog.tsx:263 -#: src/components/dialogs/VerifyEmailDialog.tsx:269 msgid "Get started" msgstr "" @@ -3685,8 +3670,8 @@ msgstr "" #: src/screens/Messages/Inbox.tsx:228 #: src/screens/Profile/ProfileFeed/index.tsx:92 #: src/screens/VideoFeed/components/Header.tsx:163 -#: src/screens/VideoFeed/index.tsx:1138 -#: src/screens/VideoFeed/index.tsx:1142 +#: src/screens/VideoFeed/index.tsx:1141 +#: src/screens/VideoFeed/index.tsx:1145 #: src/view/com/auth/LoggedOut.tsx:72 #: src/view/screens/NotFound.tsx:57 #: src/view/screens/ProfileList.tsx:1038 @@ -3737,7 +3722,7 @@ msgstr "" msgid "Go live for" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:204 +#: src/view/com/notifications/NotificationFeedItem.tsx:205 msgid "Go to {firstAuthorName}'s profile" msgstr "" @@ -3797,7 +3782,7 @@ msgstr "" msgid "Harassment, trolling, or intolerance" msgstr "" -#: src/Navigation.tsx:418 +#: src/Navigation.tsx:505 msgid "Hashtag" msgstr "" @@ -3814,8 +3799,8 @@ msgstr "" msgid "Having trouble?" msgstr "" -#: src/screens/Settings/Settings.tsx:217 -#: src/screens/Settings/Settings.tsx:221 +#: src/screens/Settings/Settings.tsx:226 +#: src/screens/Settings/Settings.tsx:230 #: src/view/shell/desktop/RightNav.tsx:120 #: src/view/shell/desktop/RightNav.tsx:121 #: src/view/shell/Drawer.tsx:370 @@ -3847,7 +3832,7 @@ msgstr "" #: src/components/interstitials/TrendingVideos.tsx:140 #: src/components/moderation/ContentHider.tsx:200 #: src/components/moderation/LabelPreference.tsx:135 -#: src/components/moderation/PostHider.tsx:124 +#: src/components/moderation/PostHider.tsx:134 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:712 #: src/lib/moderation/useLabelBehaviorDescription.ts:15 #: src/lib/moderation/useLabelBehaviorDescription.ts:20 @@ -3857,7 +3842,7 @@ msgstr "" msgid "Hide" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:755 +#: src/view/com/notifications/NotificationFeedItem.tsx:756 msgctxt "action" msgid "Hide" msgstr "" @@ -3908,7 +3893,7 @@ msgstr "" msgid "Hide trending videos?" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:746 +#: src/view/com/notifications/NotificationFeedItem.tsx:747 msgid "Hide user list" msgstr "" @@ -3918,7 +3903,7 @@ msgid "Hide verification badges" msgstr "" #: src/components/moderation/ContentHider.tsx:151 -#: src/components/moderation/PostHider.tsx:79 +#: src/components/moderation/PostHider.tsx:89 msgid "Hides the content" msgstr "" @@ -3954,9 +3939,9 @@ msgstr "" msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" -#: src/Navigation.tsx:620 -#: src/Navigation.tsx:640 -#: src/view/shell/bottom-bar/BottomBar.tsx:176 +#: src/Navigation.tsx:707 +#: src/Navigation.tsx:727 +#: src/view/shell/bottom-bar/BottomBar.tsx:178 #: src/view/shell/desktop/LeftNav.tsx:617 #: src/view/shell/Drawer.tsx:429 msgid "Home" @@ -3975,10 +3960,10 @@ msgstr "" msgid "Hot" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:67 -#: src/screens/Settings/ThreadPreferences.tsx:70 -#: src/view/com/post-thread/PostThread.tsx:655 -#: src/view/com/post-thread/PostThread.tsx:660 +#: src/screens/Settings/ThreadPreferences.tsx:201 +#: src/screens/Settings/ThreadPreferences.tsx:204 +#: src/view/com/post-thread/PostThread.tsx:679 +#: src/view/com/post-thread/PostThread.tsx:684 msgid "Hot replies first" msgstr "" @@ -3987,10 +3972,6 @@ msgstr "" msgid "How should we open this link?" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:189 -#: src/components/dialogs/ChangeEmailDialog.tsx:196 -#: src/components/dialogs/VerifyEmailDialog.tsx:302 -#: src/components/dialogs/VerifyEmailDialog.tsx:309 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:133 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:136 msgid "I have a code" @@ -4082,6 +4063,34 @@ msgstr "" msgid "Impersonation, misinformation, or false claims" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:285 +msgid "In-app" +msgstr "" + +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:134 +msgid "In-app notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:268 +msgid "In-app, Everyone" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:276 +msgid "In-app, People you follow" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:283 +msgid "In-app, Push" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:266 +msgid "In-app, Push, Everyone" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:274 +msgid "In-app, Push, People you follow" +msgstr "" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:91 #: src/lib/moderation/useReportOptions.ts:91 msgid "Inappropriate messages or explicit links" @@ -4133,7 +4142,7 @@ msgstr "" msgid "Invalid handle. Please try a different one." msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:339 +#: src/view/com/post-thread/PostThreadItem.tsx:351 msgid "Invalid or unsupported post record" msgstr "" @@ -4185,7 +4194,7 @@ msgstr "" msgid "It's just you right now! Add more people to your starter pack by searching above." msgstr "" -#: src/view/com/composer/Composer.tsx:1612 +#: src/view/com/composer/Composer.tsx:1672 msgid "Job ID: {0}" msgstr "" @@ -4244,13 +4253,13 @@ msgstr "" msgid "Language selection" msgstr "" -#: src/Navigation.tsx:190 +#: src/Navigation.tsx:200 msgid "Language Settings" msgstr "" #: src/screens/Settings/LanguageSettings.tsx:78 -#: src/screens/Settings/Settings.tsx:209 -#: src/screens/Settings/Settings.tsx:212 +#: src/screens/Settings/Settings.tsx:218 +#: src/screens/Settings/Settings.tsx:221 msgid "Languages" msgstr "" @@ -4273,7 +4282,6 @@ msgstr "" #: src/screens/Moderation/VerificationSettings.tsx:47 #: src/screens/Profile/Header/EditProfileDialog.tsx:359 #: src/screens/Settings/components/ChangeHandleDialog.tsx:212 -#: src/view/com/modals/EditProfile.tsx:207 msgid "Learn more" msgstr "" @@ -4297,7 +4305,7 @@ msgstr "" msgid "Learn more about the moderation applied to this content." msgstr "" -#: src/components/moderation/PostHider.tsx:100 +#: src/components/moderation/PostHider.tsx:110 #: src/components/moderation/ScreenHider.tsx:127 msgid "Learn more about this warning" msgstr "" @@ -4388,6 +4396,10 @@ msgstr "" msgid "Like 10 posts to train the Discover feed" msgstr "" +#: src/Navigation.tsx:426 +msgid "Like notifications" +msgstr "" + #: src/screens/Profile/components/ProfileFeedHeader.tsx:505 msgid "Like this feed" msgstr "" @@ -4396,8 +4408,8 @@ msgstr "" msgid "Like this labeler" msgstr "" -#: src/Navigation.tsx:268 -#: src/Navigation.tsx:273 +#: src/Navigation.tsx:278 +#: src/Navigation.tsx:283 msgid "Liked by" msgstr "" @@ -4419,20 +4431,34 @@ msgstr "" msgid "Liked by {likeCount, plural, one {# user} other {# users}}" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:159 +#: src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx:41 #: src/view/screens/Profile.tsx:229 msgid "Likes" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:232 +#: src/screens/Settings/NotificationSettings/index.tsx:208 +#: src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx:41 +msgid "Likes of your reposts" +msgstr "" + +#: src/Navigation.tsx:450 +msgid "Likes of your reposts notifications" +msgstr "" + +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:467 +#: src/view/com/post-thread/PostThreadItem.tsx:243 msgid "Likes on this post" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:629 -#: src/view/com/post-thread/PostThread.tsx:634 +#: src/screens/PostThread/components/HeaderDropdown.tsx:47 +#: src/screens/PostThread/components/HeaderDropdown.tsx:52 +#: src/view/com/post-thread/PostThread.tsx:653 +#: src/view/com/post-thread/PostThread.tsx:658 msgid "Linear" msgstr "" -#: src/Navigation.tsx:223 +#: src/Navigation.tsx:233 msgid "List" msgstr "" @@ -4450,11 +4476,11 @@ msgstr "" msgid "List by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:156 +#: src/view/com/profile/ProfileSubpageHeader.tsx:160 msgid "List by <0/>" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:154 +#: src/view/com/profile/ProfileSubpageHeader.tsx:158 msgid "List by you" msgstr "" @@ -4490,7 +4516,7 @@ msgctxt "toast" msgid "List unmuted" msgstr "" -#: src/Navigation.tsx:144 +#: src/Navigation.tsx:154 #: src/view/screens/Lists.tsx:65 #: src/view/screens/Profile.tsx:224 #: src/view/screens/Profile.tsx:232 @@ -4543,7 +4569,7 @@ msgstr "" msgid "Loading..." msgstr "" -#: src/Navigation.tsx:293 +#: src/Navigation.tsx:303 msgid "Log" msgstr "" @@ -4623,8 +4649,6 @@ msgstr "" #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:90 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:97 -#: src/components/dialogs/VerifyEmailDialog.tsx:273 -#: src/components/dialogs/VerifyEmailDialog.tsx:281 msgid "Maybe later" msgstr "" @@ -4636,6 +4660,10 @@ msgstr "" msgid "Media that may be disturbing or inappropriate for some audiences." msgstr "" +#: src/Navigation.tsx:410 +msgid "Mention notifications" +msgstr "" + #: src/components/WhoCanReply.tsx:263 msgid "mentioned users" msgstr "" @@ -4644,6 +4672,8 @@ msgstr "" msgid "Mentioned users" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:137 +#: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:41 #: src/view/screens/Notifications.tsx:99 msgid "Mentions" msgstr "" @@ -4686,7 +4716,7 @@ msgstr "" msgid "Message options" msgstr "" -#: src/Navigation.tsx:635 +#: src/Navigation.tsx:722 msgid "Messages" msgstr "" @@ -4695,6 +4725,10 @@ msgctxt "Name of app icon variant" msgid "Midnight" msgstr "" +#: src/Navigation.tsx:466 +msgid "Miscellaneous notifications" +msgstr "" + #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:47 #: src/lib/moderation/useReportOptions.ts:47 msgid "Misleading Account" @@ -4705,10 +4739,10 @@ msgstr "" msgid "Misleading Post" msgstr "" -#: src/Navigation.tsx:149 +#: src/Navigation.tsx:159 #: src/screens/Moderation/index.tsx:93 -#: src/screens/Settings/Settings.tsx:177 -#: src/screens/Settings/Settings.tsx:180 +#: src/screens/Settings/Settings.tsx:178 +#: src/screens/Settings/Settings.tsx:181 msgid "Moderation" msgstr "" @@ -4721,12 +4755,12 @@ msgstr "" msgid "Moderation list by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:169 +#: src/view/com/profile/ProfileSubpageHeader.tsx:173 msgid "Moderation list by <0/>" msgstr "" #: src/view/com/modals/UserAddRemoveLists.tsx:220 -#: src/view/com/profile/ProfileSubpageHeader.tsx:167 +#: src/view/com/profile/ProfileSubpageHeader.tsx:171 msgid "Moderation list by you" msgstr "" @@ -4744,7 +4778,7 @@ msgstr "" msgid "Moderation lists" msgstr "" -#: src/Navigation.tsx:154 +#: src/Navigation.tsx:164 #: src/view/screens/ModerationModlists.tsx:65 msgid "Moderation Lists" msgstr "" @@ -4753,7 +4787,7 @@ msgstr "" msgid "moderation settings" msgstr "" -#: src/Navigation.tsx:283 +#: src/Navigation.tsx:293 msgid "Moderation states" msgstr "" @@ -4766,7 +4800,7 @@ msgstr "" msgid "Moderator has chosen to set a general warning on the content." msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:721 +#: src/view/com/post-thread/PostThreadItem.tsx:731 msgid "More" msgstr "" @@ -4781,13 +4815,13 @@ msgstr "" msgid "More options" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:94 +#: src/screens/Settings/ThreadPreferences.tsx:228 msgid "Most-liked first" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:91 -#: src/view/com/post-thread/PostThread.tsx:685 -#: src/view/com/post-thread/PostThread.tsx:690 +#: src/screens/Settings/ThreadPreferences.tsx:225 +#: src/view/com/post-thread/PostThread.tsx:709 +#: src/view/com/post-thread/PostThread.tsx:714 msgid "Most-liked replies first" msgstr "" @@ -4799,8 +4833,8 @@ msgstr "" msgid "Music" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:156 -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx:95 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:156 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:96 msgctxt "video" msgid "Mute" msgstr "" @@ -4876,7 +4910,7 @@ msgstr "" msgid "Muted accounts" msgstr "" -#: src/Navigation.tsx:159 +#: src/Navigation.tsx:169 #: src/view/screens/ModerationMutedAccounts.tsx:118 msgid "Muted Accounts" msgstr "" @@ -4949,10 +4983,6 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:234 -msgid "Need to change it?" -msgstr "" - #: src/components/moderation/ReportDialog/index.tsx:288 #: src/components/ReportDialog/SelectReportOptionView.tsx:128 msgid "Need to report a copyright violation?" @@ -4982,7 +5012,6 @@ msgstr "" msgid "New chat" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:142 #: src/components/dialogs/EmailDialog/screens/Update.tsx:222 msgid "New email address" msgstr "" @@ -4991,6 +5020,15 @@ msgstr "" msgid "New Feature" msgstr "" +#: src/Navigation.tsx:442 +msgid "New follower notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:181 +#: src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx:41 +msgid "New followers" +msgstr "" + #: src/screens/Settings/components/ChangeHandleDialog.tsx:221 #: src/screens/Settings/components/ChangeHandleDialog.tsx:229 #: src/screens/Settings/components/ChangeHandleDialog.tsx:375 @@ -5046,10 +5084,14 @@ msgstr "" msgid "New User List" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:83 -#: src/screens/Settings/ThreadPreferences.tsx:86 -#: src/view/com/post-thread/PostThread.tsx:675 -#: src/view/com/post-thread/PostThread.tsx:680 +#: src/screens/PostThread/components/HeaderDropdown.tsx:93 +#: src/screens/PostThread/components/HeaderDropdown.tsx:98 +#: src/screens/Settings/ThreadPreferences.tsx:96 +#: src/screens/Settings/ThreadPreferences.tsx:99 +#: src/screens/Settings/ThreadPreferences.tsx:217 +#: src/screens/Settings/ThreadPreferences.tsx:220 +#: src/view/com/post-thread/PostThread.tsx:699 +#: src/view/com/post-thread/PostThread.tsx:704 msgid "Newest replies first" msgstr "" @@ -5219,7 +5261,7 @@ msgstr "" msgid "Not followed by anyone you're following" msgstr "" -#: src/Navigation.tsx:139 +#: src/Navigation.tsx:149 #: src/view/screens/Profile.tsx:125 msgid "Not Found" msgstr "" @@ -5240,19 +5282,12 @@ msgstr "" msgid "Nothing here" msgstr "" -#: src/screens/Settings/NotificationSettings.tsx:64 -msgid "Notification filters" -msgstr "" - -#: src/Navigation.tsx:443 +#: src/Navigation.tsx:396 +#: src/Navigation.tsx:530 #: src/view/screens/Notifications.tsx:134 msgid "Notification settings" msgstr "" -#: src/screens/Settings/NotificationSettings.tsx:46 -msgid "Notification Settings" -msgstr "" - #: src/screens/Messages/Settings.tsx:123 msgid "Notification sounds" msgstr "" @@ -5261,14 +5296,30 @@ msgstr "" msgid "Notification Sounds" msgstr "" -#: src/Navigation.tsx:630 +#: src/Navigation.tsx:717 +#: src/screens/Settings/NotificationSettings/index.tsx:92 +#: src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx:30 +#: src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx:30 +#: src/screens/Settings/Settings.tsx:186 +#: src/screens/Settings/Settings.tsx:189 #: src/view/screens/Notifications.tsx:128 -#: src/view/shell/bottom-bar/BottomBar.tsx:250 +#: src/view/shell/bottom-bar/BottomBar.tsx:252 #: src/view/shell/desktop/LeftNav.tsx:654 #: src/view/shell/Drawer.tsx:482 msgid "Notifications" msgstr "" +#: src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx:43 +msgid "Notifications for everything else, such as when someone joins via one of your starter packs." +msgstr "" + #: src/lib/hooks/useTimeAgo.ts:135 msgid "now" msgstr "" @@ -5288,6 +5339,7 @@ msgid "Nudity or adult content not labeled as such" msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:11 +#: src/screens/Settings/NotificationSettings/index.tsx:292 msgid "Off" msgstr "" @@ -5309,14 +5361,19 @@ msgid "OK" msgstr "" #: src/screens/Login/PasswordUpdatedForm.tsx:37 -#: src/view/com/post-thread/PostThreadItem.tsx:975 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:670 +#: src/view/com/post-thread/PostThreadItem.tsx:985 msgid "Okay" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:75 -#: src/screens/Settings/ThreadPreferences.tsx:78 -#: src/view/com/post-thread/PostThread.tsx:665 -#: src/view/com/post-thread/PostThread.tsx:670 +#: src/screens/PostThread/components/HeaderDropdown.tsx:83 +#: src/screens/PostThread/components/HeaderDropdown.tsx:88 +#: src/screens/Settings/ThreadPreferences.tsx:88 +#: src/screens/Settings/ThreadPreferences.tsx:91 +#: src/screens/Settings/ThreadPreferences.tsx:209 +#: src/screens/Settings/ThreadPreferences.tsx:212 +#: src/view/com/post-thread/PostThread.tsx:689 +#: src/view/com/post-thread/PostThread.tsx:694 msgid "Oldest replies first" msgstr "" @@ -5324,19 +5381,19 @@ msgstr "" msgid "on<0><1/><2><3/>" msgstr "" -#: src/screens/Settings/Settings.tsx:358 +#: src/screens/Settings/Settings.tsx:367 msgid "Onboarding reset" msgstr "" -#: src/view/com/composer/Composer.tsx:342 +#: src/view/com/composer/Composer.tsx:347 msgid "One or more GIFs is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:339 +#: src/view/com/composer/Composer.tsx:344 msgid "One or more images is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:349 +#: src/view/com/composer/Composer.tsx:354 msgid "One or more videos is missing alt text." msgstr "" @@ -5369,7 +5426,6 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:341 #: src/screens/Settings/AppPasswords.tsx:59 #: src/screens/Settings/components/ChangeHandleDialog.tsx:106 -#: src/screens/Settings/NotificationSettings.tsx:54 #: src/view/screens/Profile.tsx:125 msgid "Oops!" msgstr "" @@ -5392,7 +5448,7 @@ msgid "Open drawer menu" msgstr "" #: src/screens/Messages/components/MessageInput.web.tsx:181 -#: src/view/com/composer/Composer.tsx:1272 +#: src/view/com/composer/Composer.tsx:1320 msgid "Open emoji picker" msgstr "" @@ -5409,7 +5465,7 @@ msgstr "" msgid "Open full emoji list" msgstr "" -#: src/view/com/util/post-embeds/ExternalLinkEmbed.tsx:79 +#: src/components/Post/Embed/ExternalEmbed/index.tsx:79 msgid "Open link to {niceUrl}" msgstr "" @@ -5417,7 +5473,7 @@ msgstr "" msgid "Open message options" msgstr "" -#: src/screens/Settings/Settings.tsx:395 +#: src/screens/Settings/Settings.tsx:404 msgid "Open moderation debug page" msgstr "" @@ -5446,12 +5502,12 @@ msgstr "" msgid "Open starter pack menu" msgstr "" -#: src/screens/Settings/Settings.tsx:388 -#: src/screens/Settings/Settings.tsx:402 +#: src/screens/Settings/Settings.tsx:397 +#: src/screens/Settings/Settings.tsx:411 msgid "Open storybook page" msgstr "" -#: src/screens/Settings/Settings.tsx:381 +#: src/screens/Settings/Settings.tsx:390 msgid "Open system log" msgstr "" @@ -5487,7 +5543,7 @@ msgstr "" msgid "Opens change handle dialog" msgstr "" -#: src/view/com/post-thread/PostThreadComposePrompt.tsx:35 +#: src/view/com/post-thread/PostThreadComposePrompt.tsx:63 msgid "Opens composer" msgstr "" @@ -5495,7 +5551,7 @@ msgstr "" msgid "Opens device photo gallery" msgstr "" -#: src/view/com/composer/Composer.tsx:1273 +#: src/view/com/composer/Composer.tsx:1321 msgid "Opens emoji picker" msgstr "" @@ -5513,7 +5569,7 @@ msgstr "" msgid "Opens GIF select dialog" msgstr "" -#: src/screens/Settings/Settings.tsx:218 +#: src/screens/Settings/Settings.tsx:227 msgid "Opens helpdesk in browser" msgstr "" @@ -5533,7 +5589,7 @@ msgstr "" msgid "Opens the linked website" msgstr "" -#: src/view/com/notifications/NotificationFeedItem.tsx:849 +#: src/view/com/notifications/NotificationFeedItem.tsx:850 #: src/view/com/util/UserAvatar.tsx:581 msgid "Opens this profile" msgstr "" @@ -5635,13 +5691,13 @@ msgstr "" msgid "Password updated!" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:43 -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:140 -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:369 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:43 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:140 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:369 msgid "Pause" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:320 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:320 msgid "Pause video" msgstr "" @@ -5651,14 +5707,19 @@ msgstr "" msgid "People" msgstr "" -#: src/Navigation.tsx:210 +#: src/Navigation.tsx:220 msgid "People followed by @{0}" msgstr "" -#: src/Navigation.tsx:203 +#: src/Navigation.tsx:213 msgid "People following @{0}" msgstr "" +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:171 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:185 +msgid "People I follow" +msgstr "" + #: src/lib/media/save-image.ts:51 msgid "Permission to access your photo library was denied. Please enable it in your system settings." msgstr "" @@ -5702,7 +5763,7 @@ msgstr "" msgid "Pin to your profile" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:407 +#: src/view/com/posts/PostFeedItem.tsx:411 msgid "Pinned" msgstr "" @@ -5719,38 +5780,38 @@ msgstr "" msgid "Pinned to your feeds" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:43 -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:140 -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:370 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:43 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:140 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:370 msgid "Play" msgstr "" -#: src/view/com/util/post-embeds/ExternalGifEmbed.tsx:107 +#: src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx:111 msgid "Play {0}" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbed.tsx:134 -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:321 +#: src/components/Post/Embed/VideoEmbed/index.tsx:134 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:321 msgid "Play video" msgstr "" -#: src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx:58 +#: src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx:61 msgid "Play Video" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:42 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:42 msgid "Plays or pauses the GIF" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:141 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:141 msgid "Plays or pauses the video" msgstr "" -#: src/view/com/util/post-embeds/ExternalGifEmbed.tsx:106 +#: src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx:110 msgid "Plays the GIF" msgstr "" -#: src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx:59 +#: src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx:62 msgid "Plays the video" msgstr "" @@ -5863,10 +5924,6 @@ msgstr "" msgid "Please verify your email" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:108 -msgid "Please Verify Your Email" -msgstr "" - #: src/screens/Onboarding/index.tsx:34 #: src/screens/Onboarding/state.ts:111 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:235 @@ -5877,29 +5934,34 @@ msgstr "" msgid "Porn" msgstr "" -#: src/view/com/composer/Composer.tsx:987 -msgctxt "action" -msgid "Post" -msgstr "" - -#: src/view/com/post-thread/PostThread.tsx:540 +#: src/screens/PostThread/index.tsx:490 +#: src/view/com/post-thread/PostThread.tsx:561 msgctxt "description" msgid "Post" msgstr "" -#: src/view/com/composer/Composer.tsx:985 +#: src/view/com/composer/Composer.tsx:1031 +msgctxt "action" +msgid "Post" +msgstr "" + +#: src/view/com/composer/Composer.tsx:1029 msgctxt "action" msgid "Post All" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:224 +#: src/screens/PostThread/components/ThreadItemPostTombstone.tsx:22 +msgid "Post blocked" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:235 msgid "Post by {0}" msgstr "" -#: src/Navigation.tsx:236 -#: src/Navigation.tsx:243 -#: src/Navigation.tsx:250 -#: src/Navigation.tsx:257 +#: src/Navigation.tsx:246 +#: src/Navigation.tsx:253 +#: src/Navigation.tsx:260 +#: src/Navigation.tsx:267 msgid "Post by @{0}" msgstr "" @@ -5912,11 +5974,14 @@ msgstr "" msgid "Post failed to upload. Please check your Internet connection and try again." msgstr "" +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:132 +#: src/screens/PostThread/components/ThreadItemPost.tsx:110 +#: src/screens/PostThread/components/ThreadItemTreePost.tsx:107 #: src/screens/VideoFeed/index.tsx:529 msgid "Post has been deleted" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:269 +#: src/view/com/post-thread/PostThread.tsx:271 msgid "Post hidden" msgstr "" @@ -5934,7 +5999,7 @@ msgstr "" msgid "Post interaction settings" msgstr "" -#: src/Navigation.tsx:170 +#: src/Navigation.tsx:180 #: src/screens/ModerationInteractionSettings/index.tsx:34 msgid "Post Interaction Settings" msgstr "" @@ -5947,8 +6012,10 @@ msgstr "" msgid "Post Languages" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:264 -#: src/view/com/post-thread/PostThread.tsx:276 +#: src/screens/PostThread/components/ThreadError.tsx:32 +#: src/screens/PostThread/components/ThreadItemPostTombstone.tsx:25 +#: src/view/com/post-thread/PostThread.tsx:266 +#: src/view/com/post-thread/PostThread.tsx:278 msgid "Post not found" msgstr "" @@ -5980,11 +6047,6 @@ msgstr "" msgid "Potentially Misleading Link" msgstr "" -#: src/state/queries/notifications/settings.ts:44 -msgctxt "toast" -msgid "Preference saved" -msgstr "" - #: src/screens/Messages/components/MessageListError.tsx:19 msgid "Press to attempt reconnection" msgstr "" @@ -6008,31 +6070,29 @@ msgstr "" msgid "Primary Language" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:112 -#: src/screens/Settings/ThreadPreferences.tsx:117 +#: src/screens/Settings/ThreadPreferences.tsx:110 +#: src/screens/Settings/ThreadPreferences.tsx:115 +#: src/screens/Settings/ThreadPreferences.tsx:246 +#: src/screens/Settings/ThreadPreferences.tsx:251 msgid "Prioritize your Follows" msgstr "" -#: src/screens/Settings/NotificationSettings.tsx:67 -msgid "Priority notifications" -msgstr "" - #: src/view/shell/desktop/RightNav.tsx:110 #: src/view/shell/desktop/RightNav.tsx:111 msgid "Privacy" msgstr "" -#: src/screens/Settings/Settings.tsx:171 -#: src/screens/Settings/Settings.tsx:174 +#: src/screens/Settings/Settings.tsx:172 +#: src/screens/Settings/Settings.tsx:175 msgid "Privacy and security" msgstr "" -#: src/Navigation.tsx:379 +#: src/Navigation.tsx:389 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:36 msgid "Privacy and Security" msgstr "" -#: src/Navigation.tsx:303 +#: src/Navigation.tsx:313 #: src/screens/Settings/AboutSettings.tsx:92 #: src/screens/Settings/AboutSettings.tsx:95 #: src/view/screens/PrivacyPolicy.tsx:31 @@ -6041,7 +6101,7 @@ msgstr "" msgid "Privacy Policy" msgstr "" -#: src/view/com/composer/Composer.tsx:1675 +#: src/view/com/composer/Composer.tsx:1735 msgid "Processing video..." msgstr "" @@ -6055,7 +6115,7 @@ msgstr "" msgid "profile" msgstr "" -#: src/view/shell/bottom-bar/BottomBar.tsx:314 +#: src/view/shell/bottom-bar/BottomBar.tsx:316 #: src/view/shell/desktop/LeftNav.tsx:709 #: src/view/shell/Drawer.tsx:76 #: src/view/shell/Drawer.tsx:559 @@ -6063,7 +6123,6 @@ msgid "Profile" msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:196 -#: src/view/com/modals/EditProfile.tsx:128 msgctxt "toast" msgid "Profile updated" msgstr "" @@ -6081,25 +6140,41 @@ msgid "Public, sharable lists which can be used to drive feeds." msgstr "" #. Accessibility label for button to publish a single post -#: src/view/com/composer/Composer.tsx:967 +#: src/view/com/composer/Composer.tsx:1011 msgid "Publish post" msgstr "" #. Accessibility label for button to publish multiple posts in a thread -#: src/view/com/composer/Composer.tsx:960 +#: src/view/com/composer/Composer.tsx:1004 msgid "Publish posts" msgstr "" #. Accessibility label for button to publish multiple replies in a thread -#: src/view/com/composer/Composer.tsx:945 +#: src/view/com/composer/Composer.tsx:989 msgid "Publish replies" msgstr "" #. Accessibility label for button to publish a single reply -#: src/view/com/composer/Composer.tsx:952 +#: src/view/com/composer/Composer.tsx:996 msgid "Publish reply" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:287 +msgid "Push" +msgstr "" + +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:117 +msgid "Push notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:270 +msgid "Push, Everyone" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:278 +msgid "Push, People you follow" +msgstr "" + #: src/components/StarterPack/QrCodeDialog.tsx:134 msgid "QR code copied to your clipboard!" msgstr "" @@ -6112,8 +6187,12 @@ msgstr "" msgid "QR code saved to your camera roll!" msgstr "" -#: src/components/PostControls/RepostButton.tsx:163 -#: src/components/PostControls/RepostButton.tsx:186 +#: src/Navigation.tsx:418 +msgid "Quote notifications" +msgstr "" + +#: src/components/PostControls/RepostButton.tsx:174 +#: src/components/PostControls/RepostButton.tsx:197 #: src/components/PostControls/RepostButton.web.tsx:78 #: src/components/PostControls/RepostButton.web.tsx:85 msgid "Quote post" @@ -6127,8 +6206,8 @@ msgstr "" msgid "Quote post was successfully detached" msgstr "" -#: src/components/PostControls/RepostButton.tsx:162 -#: src/components/PostControls/RepostButton.tsx:184 +#: src/components/PostControls/RepostButton.tsx:173 +#: src/components/PostControls/RepostButton.tsx:195 #: src/components/PostControls/RepostButton.web.tsx:77 #: src/components/PostControls/RepostButton.web.tsx:84 msgid "Quote posts disabled" @@ -6139,17 +6218,20 @@ msgid "Quote settings" msgstr "" #: src/screens/Post/PostQuotes.tsx:38 +#: src/screens/Settings/NotificationSettings/index.tsx:148 +#: src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx:41 msgid "Quotes" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:258 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:451 +#: src/view/com/post-thread/PostThreadItem.tsx:269 msgid "Quotes of this post" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:99 -#: src/screens/Settings/ThreadPreferences.tsx:102 -#: src/view/com/post-thread/PostThread.tsx:695 -#: src/view/com/post-thread/PostThread.tsx:700 +#: src/screens/Settings/ThreadPreferences.tsx:233 +#: src/screens/Settings/ThreadPreferences.tsx:236 +#: src/view/com/post-thread/PostThread.tsx:719 +#: src/view/com/post-thread/PostThread.tsx:724 msgid "Random (aka \"Poster's Roulette\")" msgstr "" @@ -6170,19 +6252,27 @@ msgstr "" msgid "Reactivate your account" msgstr "" +#: src/screens/PostThread/components/ThreadItemReadMore.tsx:92 +msgid "Read {0} more {1, plural, one {reply} other {replies}}" +msgstr "" + #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:158 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:173 msgid "Read blog post" msgstr "" -#: src/screens/VideoFeed/index.tsx:963 +#: src/screens/VideoFeed/index.tsx:966 msgid "Read less" msgstr "" -#: src/screens/VideoFeed/index.tsx:963 +#: src/screens/VideoFeed/index.tsx:966 msgid "Read more" msgstr "" +#: src/screens/PostThread/components/ThreadItemReadMore.tsx:71 +msgid "Read more replies" +msgstr "" + #: src/view/com/auth/SplashScreen.web.tsx:173 msgid "Read the Bluesky blog" msgstr "" @@ -6206,6 +6296,14 @@ msgstr "" msgid "Reason:" msgstr "" +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:123 +msgid "Receive in-app notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:106 +msgid "Receive push notifications" +msgstr "" + #: src/screens/Search/components/SearchHistory.tsx:49 msgid "Recent Searches" msgstr "" @@ -6237,7 +6335,7 @@ msgstr "" #: src/components/FeedCard.tsx:343 #: src/components/StarterPack/Wizard/WizardListCard.tsx:102 #: src/components/StarterPack/Wizard/WizardListCard.tsx:109 -#: src/screens/Settings/Settings.tsx:543 +#: src/screens/Settings/Settings.tsx:552 #: src/view/com/feeds/FeedSourceCard.tsx:322 #: src/view/com/modals/UserAddRemoveLists.tsx:235 #: src/view/com/posts/PostFeedErrorMessage.tsx:213 @@ -6252,12 +6350,12 @@ msgstr "" msgid "Remove {historyItem}" msgstr "" -#: src/screens/Settings/Settings.tsx:522 -#: src/screens/Settings/Settings.tsx:525 +#: src/screens/Settings/Settings.tsx:531 +#: src/screens/Settings/Settings.tsx:534 msgid "Remove account" msgstr "" -#: src/view/com/composer/ExternalEmbedRemoveBtn.tsx:15 +#: src/view/com/composer/ExternalEmbedRemoveBtn.tsx:19 msgid "Remove attachment" msgstr "" @@ -6294,7 +6392,7 @@ msgstr "" msgid "Remove from my feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:535 +#: src/screens/Settings/Settings.tsx:544 msgid "Remove from quick access?" msgstr "" @@ -6325,12 +6423,8 @@ msgstr "" msgid "Remove profile" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:290 -msgid "Remove quote" -msgstr "" - -#: src/components/PostControls/RepostButton.tsx:140 -#: src/components/PostControls/RepostButton.tsx:150 +#: src/components/PostControls/RepostButton.tsx:151 +#: src/components/PostControls/RepostButton.tsx:161 msgid "Remove repost" msgstr "" @@ -6357,11 +6451,11 @@ msgstr "" msgid "Remove your verification for this account?" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:110 +#: src/components/Post/Embed/index.tsx:208 msgid "Removed by author" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:108 +#: src/components/Post/Embed/index.tsx:206 msgid "Removed by you" msgstr "" @@ -6388,15 +6482,13 @@ msgstr "" msgid "Removed verification" msgstr "" -#: src/view/com/util/post-embeds/QuoteEmbed.tsx:291 -msgid "Removes quoted post" -msgstr "" - #: src/view/com/posts/FeedShutdownMsg.tsx:129 #: src/view/com/posts/FeedShutdownMsg.tsx:133 msgid "Replace with Discover" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:126 +#: src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx:41 #: src/view/screens/Profile.tsx:226 msgid "Replies" msgstr "" @@ -6409,7 +6501,7 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/view/com/composer/Composer.tsx:983 +#: src/view/com/composer/Composer.tsx:1027 msgctxt "action" msgid "Reply" msgstr "" @@ -6429,6 +6521,10 @@ msgstr "" msgid "Reply Hidden by You" msgstr "" +#: src/Navigation.tsx:402 +msgid "Reply notifications" +msgstr "" + #: src/components/dialogs/PostInteractionSettingsDialog.tsx:385 msgid "Reply settings" msgstr "" @@ -6437,28 +6533,29 @@ msgstr "" msgid "Reply settings are chosen by the author of the thread" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:651 +#: src/screens/PostThread/components/HeaderDropdown.tsx:69 +#: src/view/com/post-thread/PostThread.tsx:675 msgid "Reply sorting" msgstr "" -#: src/view/com/post/Post.tsx:205 -#: src/view/com/posts/PostFeedItem.tsx:605 +#: src/view/com/post/Post.tsx:204 +#: src/view/com/posts/PostFeedItem.tsx:602 msgctxt "description" msgid "Reply to <0><1/>" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:596 +#: src/view/com/posts/PostFeedItem.tsx:593 msgctxt "description" msgid "Reply to a blocked post" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:598 +#: src/view/com/posts/PostFeedItem.tsx:595 msgctxt "description" msgid "Reply to a post" msgstr "" -#: src/view/com/post/Post.tsx:203 -#: src/view/com/posts/PostFeedItem.tsx:602 +#: src/view/com/post/Post.tsx:202 +#: src/view/com/posts/PostFeedItem.tsx:599 msgctxt "description" msgid "Reply to you" msgstr "" @@ -6560,8 +6657,8 @@ msgstr "" msgid "Report this user" msgstr "" -#: src/components/PostControls/RepostButton.tsx:141 #: src/components/PostControls/RepostButton.tsx:152 +#: src/components/PostControls/RepostButton.tsx:163 msgctxt "action" msgid "Repost" msgstr "" @@ -6572,11 +6669,15 @@ msgid "Repost" msgstr "" #. Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form -#: src/components/PostControls/RepostButton.tsx:65 +#: src/components/PostControls/RepostButton.tsx:76 msgid "Repost ({0, plural, one {# repost} other {# reposts}})" msgstr "" -#: src/components/PostControls/RepostButton.tsx:133 +#: src/Navigation.tsx:434 +msgid "Repost notifications" +msgstr "" + +#: src/components/PostControls/RepostButton.tsx:144 #: src/components/PostControls/RepostButton.web.tsx:43 #: src/components/PostControls/RepostButton.web.tsx:97 #: src/screens/StarterPack/StarterPackScreen.tsx:561 @@ -6587,26 +6688,36 @@ msgstr "" msgid "Reposted By" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:347 +#: src/view/com/posts/PostFeedItem.tsx:351 msgid "Reposted by {0}" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:366 +#: src/view/com/posts/PostFeedItem.tsx:370 msgid "Reposted by <0><1/>" msgstr "" -#: src/view/com/posts/PostFeedItem.tsx:345 -#: src/view/com/posts/PostFeedItem.tsx:364 +#: src/view/com/posts/PostFeedItem.tsx:349 +#: src/view/com/posts/PostFeedItem.tsx:368 msgid "Reposted by you" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:237 +#: src/screens/Settings/NotificationSettings/index.tsx:170 +#: src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx:41 +msgid "Reposts" +msgstr "" + +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:433 +#: src/view/com/post-thread/PostThreadItem.tsx:248 msgid "Reposts of this post" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:175 -#: src/components/dialogs/ChangeEmailDialog.tsx:182 -msgid "Request change" +#: src/screens/Settings/NotificationSettings/index.tsx:223 +#: src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx:41 +msgid "Reposts of your reposts" +msgstr "" + +#: src/Navigation.tsx:458 +msgid "Reposts of your reposts notifications" msgstr "" #: src/view/com/modals/ChangePassword.tsx:253 @@ -6635,10 +6746,6 @@ msgstr "" msgid "Resend" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:217 -#: src/components/dialogs/ChangeEmailDialog.tsx:227 -#: src/components/dialogs/VerifyEmailDialog.tsx:330 -#: src/components/dialogs/VerifyEmailDialog.tsx:340 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:173 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:176 msgid "Resend email" @@ -6661,8 +6768,8 @@ msgstr "" msgid "Reset Code" msgstr "" -#: src/screens/Settings/Settings.tsx:409 -#: src/screens/Settings/Settings.tsx:411 +#: src/screens/Settings/Settings.tsx:418 +#: src/screens/Settings/Settings.tsx:420 msgid "Reset onboarding state" msgstr "" @@ -6683,6 +6790,8 @@ msgstr "" #: src/components/Error.tsx:65 #: src/components/Lists.tsx:110 #: src/components/moderation/ReportDialog/index.tsx:229 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:55 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:57 #: src/components/StarterPack/ProfileStarterPacks.tsx:346 #: src/screens/Login/LoginForm.tsx:323 #: src/screens/Login/LoginForm.tsx:330 @@ -6691,11 +6800,11 @@ msgstr "" #: src/screens/Messages/Inbox.tsx:197 #: src/screens/Onboarding/StepInterests/index.tsx:217 #: src/screens/Onboarding/StepInterests/index.tsx:220 +#: src/screens/PostThread/components/ThreadError.tsx:75 +#: src/screens/PostThread/components/ThreadError.tsx:81 #: src/screens/Signup/BackNextButtons.tsx:53 #: src/view/com/util/error/ErrorMessage.tsx:60 #: src/view/com/util/error/ErrorScreen.tsx:97 -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx:55 -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx:57 msgid "Retry" msgstr "" @@ -6715,7 +6824,7 @@ msgstr "" #: src/screens/Profile/ProfileFeed/index.tsx:93 #: src/screens/Settings/components/ChangeHandleDialog.tsx:559 -#: src/screens/VideoFeed/index.tsx:1139 +#: src/screens/VideoFeed/index.tsx:1142 #: src/view/screens/NotFound.tsx:60 #: src/view/screens/ProfileList.tsx:1039 msgid "Returns to previous page" @@ -6741,7 +6850,6 @@ msgstr "" #: src/view/com/composer/photos/ImageAltTextDialog.tsx:153 #: src/view/com/composer/photos/ImageAltTextDialog.tsx:163 #: src/view/com/modals/CreateOrEditList.tsx:315 -#: src/view/com/modals/EditProfile.tsx:244 #: src/view/screens/SavedFeeds.tsx:109 msgid "Save" msgstr "" @@ -6761,10 +6869,6 @@ msgstr "" msgid "Save changes" msgstr "" -#: src/view/com/modals/EditProfile.tsx:252 -msgid "Save Changes" -msgstr "" - #: src/components/StarterPack/ShareDialog.tsx:131 #: src/components/StarterPack/ShareDialog.tsx:138 msgid "Save image" @@ -6796,18 +6900,14 @@ msgstr "" msgid "Saved to your feeds" msgstr "" -#: src/view/com/modals/EditProfile.tsx:245 -msgid "Saves any changes to your profile" -msgstr "" - #: src/view/com/modals/CropImage.web.tsx:105 msgid "Saves image crop settings" msgstr "" #: src/components/dms/ChatEmptyPill.tsx:33 #: src/components/NewskieDialog.tsx:105 -#: src/view/com/notifications/NotificationFeedItem.tsx:694 -#: src/view/com/notifications/NotificationFeedItem.tsx:719 +#: src/view/com/notifications/NotificationFeedItem.tsx:695 +#: src/view/com/notifications/NotificationFeedItem.tsx:720 msgid "Say hello!" msgstr "" @@ -6820,16 +6920,16 @@ msgstr "" msgid "Scroll to top" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:513 +#: src/components/dialogs/SearchablePeopleList.tsx:514 #: src/components/forms/SearchInput.tsx:34 #: src/components/forms/SearchInput.tsx:36 #: src/screens/Search/Shell.tsx:307 #: src/screens/Search/Shell.tsx:464 -#: src/view/shell/bottom-bar/BottomBar.tsx:196 +#: src/view/shell/bottom-bar/BottomBar.tsx:198 msgid "Search" msgstr "" -#: src/Navigation.tsx:229 +#: src/Navigation.tsx:239 #: src/screens/Profile/ProfileSearch.tsx:37 msgid "Search @{0}'s posts" msgstr "" @@ -6884,7 +6984,7 @@ msgstr "" msgid "Search posts" msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:533 +#: src/components/dialogs/SearchablePeopleList.tsx:534 #: src/components/ProgressGuide/FollowDialog.tsx:702 msgid "Search profiles" msgstr "" @@ -6897,7 +6997,7 @@ msgstr "" msgid "Search..." msgstr "" -#: src/components/dialogs/SearchablePeopleList.tsx:534 +#: src/components/dialogs/SearchablePeopleList.tsx:535 #: src/components/ProgressGuide/FollowDialog.tsx:703 msgid "Searches for profiles" msgstr "" @@ -6906,10 +7006,6 @@ msgstr "" msgid "Security step required" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:57 -msgid "Security Step Required" -msgstr "" - #: src/components/RichTextTag.tsx:111 msgid "See {tag} posts" msgstr "" @@ -6934,7 +7030,7 @@ msgstr "" msgid "See this guide" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx:194 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:195 msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause" msgstr "" @@ -7044,7 +7140,7 @@ msgid "Select your date of birth" msgstr "" #: src/screens/Onboarding/StepInterests/index.tsx:192 -#: src/screens/Settings/SettingsInterests.tsx:162 +#: src/screens/Settings/InterestsSettings.tsx:165 msgid "Select your interests from the options below" msgstr "" @@ -7060,14 +7156,6 @@ msgstr "" msgid "Send a neat website!" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:295 -msgid "Send confirmation" -msgstr "" - -#: src/components/dialogs/VerifyEmailDialog.tsx:288 -msgid "Send confirmation email" -msgstr "" - #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:174 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:181 #: src/components/dialogs/EmailDialog/screens/Verify.tsx:300 @@ -7153,13 +7241,49 @@ msgstr "" msgid "Sets email for password reset" msgstr "" -#: src/Navigation.tsx:185 -#: src/screens/Settings/Settings.tsx:90 +#: src/Navigation.tsx:195 +#: src/screens/Settings/Settings.tsx:91 #: src/view/shell/desktop/LeftNav.tsx:727 #: src/view/shell/Drawer.tsx:572 msgid "Settings" msgstr "" +#: src/screens/Settings/NotificationSettings/index.tsx:154 +msgid "Settings for like notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:132 +msgid "Settings for mention notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:176 +msgid "Settings for new follower notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:231 +msgid "Settings for notifications for everything else" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:202 +msgid "Settings for notifications for likes of your reposts" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:217 +msgid "Settings for notifications for reposts of your reposts" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:143 +msgid "Settings for quote notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:121 +msgid "Settings for reply notifications" +msgstr "" + +#: src/screens/Settings/NotificationSettings/index.tsx:165 +msgid "Settings for repost notifications" +msgstr "" + #: src/screens/ModerationInteractionSettings/index.tsx:102 msgctxt "toast" msgid "Settings saved" @@ -7252,7 +7376,7 @@ msgstr "" msgid "Share your favorite feed!" msgstr "" -#: src/Navigation.tsx:288 +#: src/Navigation.tsx:298 msgid "Shared Preferences Tester" msgstr "" @@ -7262,11 +7386,11 @@ msgstr "" #: src/components/moderation/ContentHider.tsx:200 #: src/components/moderation/LabelPreference.tsx:137 -#: src/components/moderation/PostHider.tsx:124 +#: src/components/moderation/PostHider.tsx:134 msgid "Show" msgstr "" -#: src/view/com/util/post-embeds/GifEmbed.tsx:178 +#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:178 msgid "Show alt text" msgstr "" @@ -7304,9 +7428,7 @@ msgstr "" msgid "Show list anyway" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:681 -#: src/view/com/post/Post.tsx:244 -#: src/view/com/posts/PostFeedItem.tsx:561 +#: src/components/Post/ShowMoreTextButton.tsx:51 msgid "Show More" msgstr "" @@ -7315,10 +7437,18 @@ msgstr "" msgid "Show more like this" msgstr "" +#: src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx:14 +msgid "Show more replies" +msgstr "" + #: src/view/com/post-thread/PostThreadShowHiddenReplies.tsx:22 msgid "Show muted replies" msgstr "" +#: src/screens/Settings/ThreadPreferences.tsx:143 +msgid "Show post replies in a threaded tree view" +msgstr "" + #: src/screens/Settings/FollowingFeedPreferences.tsx:104 #: src/screens/Settings/FollowingFeedPreferences.tsx:114 msgid "Show quote posts" @@ -7329,15 +7459,17 @@ msgstr "" msgid "Show replies" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:625 +#: src/screens/PostThread/components/HeaderDropdown.tsx:43 +#: src/view/com/post-thread/PostThread.tsx:649 msgid "Show replies as" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:151 +#: src/screens/Settings/ThreadPreferences.tsx:285 msgid "Show replies as threaded" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:126 +#: src/screens/Settings/ThreadPreferences.tsx:120 +#: src/screens/Settings/ThreadPreferences.tsx:260 msgid "Show replies by people you follow before all other replies" msgstr "" @@ -7364,16 +7496,17 @@ msgstr "" msgid "Show warning and filter from feeds" msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:916 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:611 +#: src/view/com/post-thread/PostThreadItem.tsx:926 msgid "Shows information about when this post was created" msgstr "" -#: src/screens/Settings/Settings.tsx:114 +#: src/screens/Settings/Settings.tsx:115 msgid "Shows other accounts you can switch to" msgstr "" #: src/components/moderation/ContentHider.tsx:152 -#: src/components/moderation/PostHider.tsx:79 +#: src/components/moderation/PostHider.tsx:89 msgid "Shows the content" msgstr "" @@ -7386,10 +7519,10 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:69 #: src/view/com/auth/SplashScreen.web.tsx:123 #: src/view/com/auth/SplashScreen.web.tsx:131 -#: src/view/shell/bottom-bar/BottomBar.tsx:353 -#: src/view/shell/bottom-bar/BottomBar.tsx:358 -#: src/view/shell/bottom-bar/BottomBarWeb.tsx:209 -#: src/view/shell/bottom-bar/BottomBarWeb.tsx:214 +#: src/view/shell/bottom-bar/BottomBar.tsx:355 +#: src/view/shell/bottom-bar/BottomBar.tsx:360 +#: src/view/shell/bottom-bar/BottomBarWeb.tsx:216 +#: src/view/shell/bottom-bar/BottomBarWeb.tsx:221 #: src/view/shell/NavSignupCard.tsx:57 #: src/view/shell/NavSignupCard.tsx:62 msgid "Sign in" @@ -7425,9 +7558,9 @@ msgstr "" msgid "Sign in to view post" msgstr "" -#: src/screens/Settings/Settings.tsx:235 -#: src/screens/Settings/Settings.tsx:237 -#: src/screens/Settings/Settings.tsx:269 +#: src/screens/Settings/Settings.tsx:244 +#: src/screens/Settings/Settings.tsx:246 +#: src/screens/Settings/Settings.tsx:278 #: src/screens/SignupQueued.tsx:93 #: src/screens/SignupQueued.tsx:96 #: src/screens/Takendown.tsx:85 @@ -7441,7 +7574,7 @@ msgstr "" msgid "Sign Out" msgstr "" -#: src/screens/Settings/Settings.tsx:266 +#: src/screens/Settings/Settings.tsx:275 #: src/view/shell/desktop/LeftNav.tsx:209 msgid "Sign out?" msgstr "" @@ -7456,7 +7589,7 @@ msgstr "" msgid "Signed in as @{0}" msgstr "" -#: src/components/FeedInterstitials.tsx:337 +#: src/components/FeedInterstitials.tsx:343 msgid "Similar accounts" msgstr "" @@ -7474,7 +7607,6 @@ msgid "Smaller" msgstr "" #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:91 -#: src/components/dialogs/VerifyEmailDialog.tsx:274 msgid "Snoozes the reminder" msgstr "" @@ -7487,7 +7619,7 @@ msgstr "" msgid "Some of your verifications are invalid." msgstr "" -#: src/components/FeedInterstitials.tsx:474 +#: src/components/FeedInterstitials.tsx:480 msgid "Some other feeds you might like" msgstr "" @@ -7524,10 +7656,13 @@ msgid "Something went wrong, please try again." msgstr "" #: src/components/Lists.tsx:174 -#: src/screens/Settings/NotificationSettings.tsx:55 msgid "Something went wrong!" msgstr "" +#: src/screens/PostThread/components/ThreadError.tsx:27 +msgid "Something went wrong. Please try again in a moment." +msgstr "" + #: src/components/moderation/ReportDialog/index.tsx:178 msgid "Something went wrong. Please try again." msgstr "" @@ -7541,15 +7676,18 @@ msgstr "" msgid "Sorry! Your session expired. Please sign in again." msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:55 +#: src/screens/Settings/ThreadPreferences.tsx:68 +#: src/screens/Settings/ThreadPreferences.tsx:189 msgid "Sort replies" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:62 +#: src/screens/Settings/ThreadPreferences.tsx:75 +#: src/screens/Settings/ThreadPreferences.tsx:196 msgid "Sort replies by" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:59 +#: src/screens/Settings/ThreadPreferences.tsx:72 +#: src/screens/Settings/ThreadPreferences.tsx:193 msgid "Sort replies to the same post by:" msgstr "" @@ -7598,8 +7736,8 @@ msgstr "" msgid "Start chat with {displayName}" msgstr "" -#: src/Navigation.tsx:453 -#: src/Navigation.tsx:458 +#: src/Navigation.tsx:540 +#: src/Navigation.tsx:545 #: src/screens/StarterPack/Wizard/index.tsx:186 msgid "Starter Pack" msgstr "" @@ -7608,12 +7746,12 @@ msgstr "" msgid "Starter pack by {0}" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:182 +#: src/view/com/profile/ProfileSubpageHeader.tsx:186 msgid "Starter pack by <0/>" msgstr "" #: src/components/StarterPack/StarterPackCard.tsx:89 -#: src/view/com/profile/ProfileSubpageHeader.tsx:180 +#: src/view/com/profile/ProfileSubpageHeader.tsx:184 msgid "Starter pack by you" msgstr "" @@ -7639,12 +7777,12 @@ msgstr "" msgid "Step {0} of {1}" msgstr "" -#: src/screens/Settings/Settings.tsx:363 +#: src/screens/Settings/Settings.tsx:372 msgid "Storage cleared, you need to restart the app now." msgstr "" -#: src/Navigation.tsx:278 -#: src/screens/Settings/Settings.tsx:390 +#: src/Navigation.tsx:288 +#: src/screens/Settings/Settings.tsx:399 msgid "Storybook" msgstr "" @@ -7690,7 +7828,6 @@ msgid "Subscribe to this list" msgstr "" #: src/components/dialogs/EmailDialog/screens/Update.tsx:286 -#: src/components/dialogs/VerifyEmailDialog.tsx:124 msgid "Success!" msgstr "" @@ -7702,7 +7839,7 @@ msgstr "" msgid "Suggested Accounts" msgstr "" -#: src/components/FeedInterstitials.tsx:339 +#: src/components/FeedInterstitials.tsx:345 msgid "Suggested for you" msgstr "" @@ -7721,15 +7858,15 @@ msgctxt "Name of app icon variant" msgid "Sunset" msgstr "" -#: src/Navigation.tsx:298 +#: src/Navigation.tsx:308 #: src/view/screens/Support.tsx:31 #: src/view/screens/Support.tsx:34 msgid "Support" msgstr "" -#: src/screens/Settings/Settings.tsx:112 -#: src/screens/Settings/Settings.tsx:126 -#: src/screens/Settings/Settings.tsx:485 +#: src/screens/Settings/Settings.tsx:113 +#: src/screens/Settings/Settings.tsx:127 +#: src/screens/Settings/Settings.tsx:494 #: src/view/shell/desktop/LeftNav.tsx:246 msgid "Switch account" msgstr "" @@ -7756,7 +7893,7 @@ msgstr "" #: src/screens/Settings/AboutSettings.tsx:107 #: src/screens/Settings/AboutSettings.tsx:110 -#: src/screens/Settings/Settings.tsx:383 +#: src/screens/Settings/Settings.tsx:392 msgid "System log" msgstr "" @@ -7808,7 +7945,7 @@ msgstr "" msgid "Terms" msgstr "" -#: src/Navigation.tsx:308 +#: src/Navigation.tsx:318 #: src/screens/Settings/AboutSettings.tsx:84 #: src/screens/Settings/AboutSettings.tsx:87 #: src/view/screens/TermsOfService.tsx:31 @@ -7845,10 +7982,6 @@ msgstr "" msgid "Thank you for your feedback! It has been sent to the feed operator." msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:125 -msgid "Thank you! Your email has been successfully verified." -msgstr "" - #: src/components/ReportDialog/SubmitView.tsx:83 msgid "Thank you. Your report has been sent." msgstr "" @@ -7878,7 +8011,7 @@ msgstr "" msgid "That's all, folks!" msgstr "" -#: src/screens/VideoFeed/index.tsx:1111 +#: src/screens/VideoFeed/index.tsx:1114 msgid "That's everything!" msgstr "" @@ -7917,10 +8050,6 @@ msgstr "" msgid "The Discover feed now knows what you like" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:74 -msgid "The email address you entered is the same as your current email address." -msgstr "" - #: src/screens/StarterPack/StarterPackLandingScreen.tsx:324 msgid "The experience is better in the app. Download Bluesky now and we'll pick back up where you left off." msgstr "" @@ -7941,8 +8070,8 @@ msgstr "" msgid "The following settings will be used as your defaults when creating new posts. You can edit these for a specific post from the composer." msgstr "" -#: src/view/com/post-thread/PostThread.tsx:265 -#: src/view/com/post-thread/PostThread.tsx:277 +#: src/view/com/post-thread/PostThread.tsx:267 +#: src/view/com/post-thread/PostThread.tsx:279 msgid "The post may have been deleted." msgstr "" @@ -8230,7 +8359,8 @@ msgstr "" msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us." msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:956 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:651 +#: src/view/com/post-thread/PostThreadItem.tsx:966 msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" @@ -8238,7 +8368,7 @@ msgstr "" msgid "This post has an unknown type of threadgate on it. Your app may be out of date." msgstr "" -#: src/view/com/post-thread/PostThreadItem.tsx:163 +#: src/view/com/post-thread/PostThreadItem.tsx:171 msgid "This post has been deleted." msgstr "" @@ -8250,7 +8380,7 @@ msgstr "" msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" -#: src/view/com/composer/Composer.tsx:424 +#: src/view/com/composer/Composer.tsx:463 msgid "This post's author has disabled quote posts." msgstr "" @@ -8311,7 +8441,7 @@ msgstr "" msgid "This will delete \"{0}\" from your muted words. You can always add it back later." msgstr "" -#: src/screens/Settings/Settings.tsx:537 +#: src/screens/Settings/Settings.tsx:546 msgid "This will remove @{0} from the quick access list." msgstr "" @@ -8319,8 +8449,10 @@ msgstr "" msgid "This will remove your post from this quote post for all users, and replace it with a placeholder." msgstr "" -#: src/view/com/post-thread/PostThread.tsx:609 -#: src/view/com/post-thread/PostThread.tsx:612 +#: src/screens/PostThread/components/HeaderDropdown.tsx:23 +#: src/screens/PostThread/components/HeaderDropdown.tsx:26 +#: src/view/com/post-thread/PostThread.tsx:633 +#: src/view/com/post-thread/PostThread.tsx:636 msgid "Thread options" msgstr "" @@ -8329,24 +8461,27 @@ msgstr "" msgid "Thread preferences" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:45 +#: src/screens/Settings/ThreadPreferences.tsx:58 +#: src/screens/Settings/ThreadPreferences.tsx:179 msgid "Thread Preferences" msgstr "" -#: src/view/com/post-thread/PostThread.tsx:639 -#: src/view/com/post-thread/PostThread.tsx:644 +#: src/screens/PostThread/components/HeaderDropdown.tsx:57 +#: src/screens/PostThread/components/HeaderDropdown.tsx:62 +#: src/view/com/post-thread/PostThread.tsx:663 +#: src/view/com/post-thread/PostThread.tsx:668 msgid "Threaded" msgstr "" -#: src/screens/Settings/ThreadPreferences.tsx:142 +#: src/screens/Settings/ThreadPreferences.tsx:276 msgid "Threaded mode" msgstr "" -#: src/Navigation.tsx:341 +#: src/Navigation.tsx:351 msgid "Threads Preferences" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx:34 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx:34 msgid "Time remaining: {0, plural, one {# second} other {# seconds}}" msgstr "" @@ -8379,7 +8514,7 @@ msgstr "" msgid "Toggle to enable or disable adult content" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:158 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:158 msgid "Toggles the sound" msgstr "" @@ -8389,7 +8524,14 @@ msgstr "" msgid "Top" msgstr "" -#: src/Navigation.tsx:423 +#: src/screens/PostThread/components/HeaderDropdown.tsx:73 +#: src/screens/PostThread/components/HeaderDropdown.tsx:78 +#: src/screens/Settings/ThreadPreferences.tsx:80 +#: src/screens/Settings/ThreadPreferences.tsx:83 +msgid "Top replies first" +msgstr "" + +#: src/Navigation.tsx:510 msgid "Topic" msgstr "" @@ -8397,11 +8539,18 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:145 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:444 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:446 -#: src/view/com/post-thread/PostThreadItem.tsx:878 -#: src/view/com/post-thread/PostThreadItem.tsx:881 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:573 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:576 +#: src/view/com/post-thread/PostThreadItem.tsx:888 +#: src/view/com/post-thread/PostThreadItem.tsx:891 msgid "Translate" msgstr "" +#: src/screens/Settings/ThreadPreferences.tsx:131 +#: src/screens/Settings/ThreadPreferences.tsx:136 +msgid "Tree view" +msgstr "" + #: src/view/shell/desktop/SidebarTrendingTopics.tsx:59 msgid "Trending" msgstr "" @@ -8496,7 +8645,7 @@ msgid "Undo repost" msgstr "" #. Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun -#: src/components/PostControls/RepostButton.tsx:55 +#: src/components/PostControls/RepostButton.tsx:66 msgid "Undo repost ({0, plural, one {# repost} other {# reposts}})" msgstr "" @@ -8535,8 +8684,8 @@ msgstr "" msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:155 -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx:94 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:95 msgctxt "video" msgid "Unmute" msgstr "" @@ -8570,7 +8719,7 @@ msgstr "" msgid "Unmute thread" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx:318 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:318 msgid "Unmute video" msgstr "" @@ -8609,8 +8758,8 @@ msgstr "" msgid "Unpinned from your feeds" msgstr "" -#: src/screens/Settings/Settings.tsx:416 -#: src/screens/Settings/Settings.tsx:418 +#: src/screens/Settings/Settings.tsx:425 +#: src/screens/Settings/Settings.tsx:427 msgid "Unsnooze email reminder" msgstr "" @@ -8631,7 +8780,7 @@ msgstr "" msgid "Unsubscribed from list" msgstr "" -#: src/view/com/composer/Composer.tsx:769 +#: src/view/com/composer/Composer.tsx:811 msgid "Unsupported video type" msgstr "" @@ -8721,7 +8870,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:1672 +#: src/view/com/composer/Composer.tsx:1732 msgid "Uploading video..." msgstr "" @@ -8845,7 +8994,7 @@ msgstr "" msgid "Verification settings" msgstr "" -#: src/Navigation.tsx:178 +#: src/Navigation.tsx:188 #: src/screens/Moderation/VerificationSettings.tsx:32 msgid "Verification Settings" msgstr "" @@ -8875,17 +9024,10 @@ msgstr "" msgid "Verify DNS Record" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:234 -#: src/components/dialogs/ChangeEmailDialog.tsx:240 -msgid "Verify email" -msgstr "" - #: src/components/dialogs/EmailDialog/screens/Verify.tsx:214 msgid "Verify email code" msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:122 -#: src/components/dialogs/VerifyEmailDialog.tsx:163 #: src/components/intents/VerifyEmailIntentDialog.tsx:67 msgid "Verify email dialog" msgstr "" @@ -8905,17 +9047,13 @@ msgstr "" msgid "Verify your email" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:114 -msgid "Verify Your Email" -msgstr "" - #: src/screens/Settings/AboutSettings.tsx:126 #: src/screens/Settings/AboutSettings.tsx:155 msgid "Version {appVersion}" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:83 -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:134 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:83 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:134 msgid "Video" msgstr "" @@ -8923,7 +9061,7 @@ msgstr "" msgid "Video failed to process" msgstr "" -#: src/Navigation.tsx:474 +#: src/Navigation.tsx:561 msgid "Video Feed" msgstr "" @@ -8937,15 +9075,15 @@ msgstr "" msgid "Video Games" msgstr "" -#: src/screens/VideoFeed/index.tsx:1069 +#: src/screens/VideoFeed/index.tsx:1072 msgid "Video is paused" msgstr "" -#: src/screens/VideoFeed/index.tsx:1069 +#: src/screens/VideoFeed/index.tsx:1072 msgid "Video is playing" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbed.web.tsx:191 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:192 msgid "Video not found." msgstr "" @@ -8953,11 +9091,11 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:1682 +#: src/view/com/composer/Composer.tsx:1742 msgid "Video uploaded" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx:83 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:83 msgid "Video: {0}" msgstr "" @@ -8978,7 +9116,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:454 #: src/screens/Search/components/SearchProfileCard.tsx:36 #: src/screens/VideoFeed/index.tsx:790 -#: src/view/com/notifications/NotificationFeedItem.tsx:545 +#: src/view/com/notifications/NotificationFeedItem.tsx:546 msgid "View {0}'s profile" msgstr "" @@ -9033,7 +9171,7 @@ msgstr "" msgid "View profile" msgstr "" -#: src/view/com/profile/ProfileSubpageHeader.tsx:119 +#: src/view/com/profile/ProfileSubpageHeader.tsx:123 msgid "View the avatar" msgstr "" @@ -9062,7 +9200,7 @@ msgid "View your default post interaction settings" msgstr "" #: src/view/com/home/HomeHeaderLayout.web.tsx:56 -#: src/view/com/home/HomeHeaderLayoutMobile.tsx:77 +#: src/view/com/home/HomeHeaderLayoutMobile.tsx:71 msgid "View your feeds and explore more" msgstr "" @@ -9078,8 +9216,8 @@ msgstr "" msgid "View your verifications" msgstr "" -#: src/view/com/util/images/AutoSizedImage.tsx:199 -#: src/view/com/util/images/AutoSizedImage.tsx:221 +#: src/view/com/util/images/AutoSizedImage.tsx:205 +#: src/view/com/util/images/AutoSizedImage.tsx:227 msgid "Views full image" msgstr "" @@ -9092,7 +9230,7 @@ msgstr "" msgid "Visit Site" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx:80 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:81 msgid "Volume" msgstr "" @@ -9143,7 +9281,7 @@ msgstr "" msgid "We ran out of posts from your follows. Here's the latest from <0/>." msgstr "" -#: src/screens/Settings/SettingsInterests.tsx:155 +#: src/screens/Settings/InterestsSettings.tsx:158 msgid "We recommend selecting at least two interests." msgstr "" @@ -9204,7 +9342,7 @@ msgstr "" msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "" -#: src/view/com/composer/Composer.tsx:421 +#: src/view/com/composer/Composer.tsx:460 msgid "We're sorry! The post you are replying to has been deleted." msgstr "" @@ -9235,7 +9373,7 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:38 #: src/view/com/auth/SplashScreen.web.tsx:99 -#: src/view/com/composer/Composer.tsx:732 +#: src/view/com/composer/Composer.tsx:774 msgid "What's up?" msgstr "" @@ -9317,12 +9455,12 @@ msgstr "" msgid "Write a message" msgstr "" -#: src/view/com/composer/Composer.tsx:823 +#: src/view/com/composer/Composer.tsx:867 msgid "Write post" msgstr "" -#: src/view/com/composer/Composer.tsx:730 -#: src/view/com/post-thread/PostThreadComposePrompt.tsx:69 +#: src/view/com/composer/Composer.tsx:772 +#: src/view/com/post-thread/PostThreadComposePrompt.tsx:90 msgid "Write your reply" msgstr "" @@ -9415,7 +9553,6 @@ msgid "You are verified" msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:355 -#: src/view/com/modals/EditProfile.tsx:203 msgid "You are verified. You will lose your verification status if you change your display name. <0>Learn more." msgstr "" @@ -9483,7 +9620,7 @@ msgstr "" msgid "You don't have any saved feeds." msgstr "" -#: src/view/com/post-thread/PostThread.tsx:271 +#: src/view/com/post-thread/PostThread.tsx:273 msgid "You have blocked the author or you have been blocked by the author." msgstr "" @@ -9603,6 +9740,11 @@ msgstr "" msgid "You must select at least one labeler for a report" msgstr "" +#: src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx:27 +#: src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx:47 +msgid "You must sign in to view this post." +msgstr "" + #: src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx:23 msgid "You need to verify your email address before you can enable email 2FA." msgstr "" @@ -9611,7 +9753,7 @@ msgstr "" msgid "You previously deactivated @{0}." msgstr "" -#: src/screens/Settings/Settings.tsx:374 +#: src/screens/Settings/Settings.tsx:383 msgid "You probably want to restart the app now." msgstr "" @@ -9623,7 +9765,7 @@ msgstr "" msgid "You reacted {0} to {1}" msgstr "" -#: src/screens/Settings/Settings.tsx:267 +#: src/screens/Settings/Settings.tsx:276 #: src/view/shell/desktop/LeftNav.tsx:210 msgid "You will be signed out of all your accounts." msgstr "" @@ -9668,10 +9810,6 @@ msgstr "" msgid "You'll follow these people right away" msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:216 -msgid "You'll receive an email at <0>{0} to verify it's you." -msgstr "" - #: src/screens/StarterPack/StarterPackLandingScreen.tsx:274 msgid "You'll stay updated with these feeds" msgstr "" @@ -9714,7 +9852,7 @@ msgstr "" msgid "You've reached your daily limit for video uploads (too many videos)" msgstr "" -#: src/screens/VideoFeed/index.tsx:1120 +#: src/screens/VideoFeed/index.tsx:1123 msgid "You've run out of videos to watch. Maybe it's a good time to take a break?" msgstr "" @@ -9750,7 +9888,7 @@ msgstr "" msgid "Your birth date" msgstr "" -#: src/view/com/util/post-embeds/VideoEmbed.web.tsx:195 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:196 msgid "Your browser does not support the video format. Please try a different browser." msgstr "" @@ -9766,10 +9904,6 @@ msgstr "" msgid "Your current handle <0>{0} will automatically remain reserved for you. You can switch back to it at any time from this account." msgstr "" -#: src/components/dialogs/ChangeEmailDialog.tsx:65 -msgid "Your email address has been updated but it is not yet verified. As a next step, please verify your new email." -msgstr "" - #: src/screens/Login/ForgotPasswordForm.tsx:51 #: src/screens/Signup/state.ts:270 #: src/screens/Signup/StepInfo/index.tsx:98 @@ -9781,10 +9915,6 @@ msgstr "" msgid "Your email has not yet been verified. Please verify your email in order to enjoy all the features of Bluesky." msgstr "" -#: src/components/dialogs/VerifyEmailDialog.tsx:110 -msgid "Your email has not yet been verified. This is an important security step which we recommend." -msgstr "" - #: src/state/shell/progress-guide.tsx:213 msgid "Your first like!" msgstr "" @@ -9805,15 +9935,15 @@ msgstr "" msgid "Your full username will be <0>@{0}" msgstr "" -#: src/Navigation.tsx:395 +#: src/Navigation.tsx:482 #: src/screens/Search/modules/ExploreInterestsCard.tsx:67 #: src/screens/Settings/ContentAndMediaSettings.tsx:92 #: src/screens/Settings/ContentAndMediaSettings.tsx:95 -#: src/screens/Settings/SettingsInterests.tsx:39 +#: src/screens/Settings/InterestsSettings.tsx:42 msgid "Your interests" msgstr "" -#: src/screens/Settings/SettingsInterests.tsx:124 +#: src/screens/Settings/InterestsSettings.tsx:127 msgctxt "toast" msgid "Your interests have been updated!" msgstr "" @@ -9834,11 +9964,11 @@ msgstr "" msgid "Your password must be at least 8 characters long." msgstr "" -#: src/view/com/composer/Composer.tsx:481 +#: src/view/com/composer/Composer.tsx:522 msgid "Your post has been published" msgstr "" -#: src/view/com/composer/Composer.tsx:478 +#: src/view/com/composer/Composer.tsx:519 msgid "Your posts have been published" msgstr "" @@ -9850,7 +9980,7 @@ msgstr "" msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in." msgstr "" -#: src/view/com/composer/Composer.tsx:480 +#: src/view/com/composer/Composer.tsx:521 msgid "Your reply has been published" msgstr "" @@ -9862,7 +9992,7 @@ msgstr "" msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" -#: src/screens/Settings/SettingsInterests.tsx:53 +#: src/screens/Settings/InterestsSettings.tsx:56 msgid "Your selected interests help us serve you content you care about." msgstr "" diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index d01a92825b..31af1be2b0 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -434,4 +434,13 @@ export type MetricEvents = { 'share:press:dmSelected': {} 'share:press:recentDm': {} 'share:press:embed': {} + + 'thread:click:showOtherReplies': {} + 'thread:preferences:load': { + [key: string]: any + } + 'thread:preferences:update': { + [key: string]: any + } + 'thread:click:headerMenuOpen': {} } diff --git a/src/logger/sentry/setup/index.ts b/src/logger/sentry/setup/index.ts index 3819211f3c..f05a7fc833 100644 --- a/src/logger/sentry/setup/index.ts +++ b/src/logger/sentry/setup/index.ts @@ -38,4 +38,12 @@ init({ */ `Network request failed`, ], + /** + * Does not affect traces of error events or other logs, just disables + * automatically attaching stack traces to events. This helps us group events + * and prevents explosions of separate issues. + * + * @see https://docs.sentry.io/platforms/react-native/configuration/options/#attach-stacktrace + */ + attachStacktrace: false, }) diff --git a/src/logger/types.ts b/src/logger/types.ts index d14e21a9d4..88d8d9d93d 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -10,6 +10,8 @@ export enum LogContext { ConversationAgent = 'conversation-agent', DMsAgent = 'dms-agent', ReportDialog = 'report-dialog', + FeedFeedback = 'feed-feedback', + PostSource = 'post-source', /** * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this diff --git a/src/routes.ts b/src/routes.ts index 60bb65dd5a..b66a0ae53f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,11 +1,17 @@ import {Router} from '#/lib/routes/router' +import {type FlatNavigatorParams} from './lib/routes/types' -export const router = new Router({ +type AllNavigatableRoutes = Omit< + FlatNavigatorParams, + 'NotFound' | 'SharedPreferencesTester' +> + +export const router = new Router({ Home: '/', Search: '/search', Feeds: '/feeds', Notifications: '/notifications', - NotificationSettings: '/notifications/settings', + LegacyNotificationSettings: '/notifications/settings', Settings: '/settings', Lists: '/lists', // moderation @@ -42,13 +48,25 @@ export const router = new Router({ AccessibilitySettings: '/settings/accessibility', AppearanceSettings: '/settings/appearance', SavedFeeds: '/settings/saved-feeds', - // new settings AccountSettings: '/settings/account', PrivacyAndSecuritySettings: '/settings/privacy-and-security', ContentAndMediaSettings: '/settings/content-and-media', - SettingsInterests: '/settings/interests', + InterestsSettings: '/settings/interests', AboutSettings: '/settings/about', AppIconSettings: '/settings/app-icon', + NotificationSettings: '/settings/notifications', + ReplyNotificationSettings: '/settings/notifications/replies', + MentionNotificationSettings: '/settings/notifications/mentions', + QuoteNotificationSettings: '/settings/notifications/quotes', + LikeNotificationSettings: '/settings/notifications/likes', + RepostNotificationSettings: '/settings/notifications/reposts', + NewFollowerNotificationSettings: '/settings/notifications/new-followers', + LikesOnRepostsNotificationSettings: + '/settings/notifications/likes-on-reposts', + RepostsOnRepostsNotificationSettings: + '/settings/notifications/reposts-on-reposts', + ActivityNotificationSettings: '/settings/notifications/activity', + MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous', // support Support: '/support', PrivacyPolicy: '/support/privacy', diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 6c3d7633ad..b6a528e42b 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -3,12 +3,12 @@ import { ActivityIndicator, Keyboard, LayoutAnimation, - TextInput, + type TextInput, View, } from 'react-native' import { ComAtprotoServerCreateSession, - ComAtprotoServerDescribeServer, + type ComAtprotoServerDescribeServer, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index f37e7a9ba1..0b8c88b9dd 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -2,9 +2,9 @@ import {useCallback} from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {isNative} from '#/platform/detection' import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery} from '#/state/queries/profile' diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index ce33ca3aa9..c84371f2c0 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -16,6 +16,7 @@ import { RichText, } from '@atproto/api' +import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { @@ -106,6 +107,8 @@ export function MessagesList({ const getPost = useGetPost() const {embedUri, setEmbed} = useMessageEmbed() + useHideBottomBarBorderForScreen() + const flatListRef = useAnimatedRef() const [newMessagesPill, setNewMessagesPill] = useState({ diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 0e738f1456..3d2c551e94 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View} from 'react-native' import {Image as ExpoImage} from 'expo-image' import { - ImagePickerOptions, + type ImagePickerOptions, launchImageLibraryAsync, MediaTypeOptions, } from 'expo-image-picker' @@ -27,7 +27,7 @@ import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreato import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' import { PlaceholderCanvas, - PlaceholderCanvasRef, + type PlaceholderCanvasRef, } from '#/screens/Onboarding/StepProfile/PlaceholderCanvas' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -38,7 +38,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive' import {Text} from '#/components/Typography' -import {AvatarColor, avatarColors, Emoji, emojiItems} from './types' +import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' export interface Avatar { image?: { diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx new file mode 100644 index 0000000000..def3979b78 --- /dev/null +++ b/src/screens/PostThread/components/HeaderDropdown.tsx @@ -0,0 +1,106 @@ +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {logger} from '#/logger' +import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' +import {Button, ButtonIcon} from '#/components/Button' +import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' +import * as Menu from '#/components/Menu' + +export function HeaderDropdown({ + sort, + view, + setSort, + setView, +}: Pick< + ThreadPreferences, + 'sort' | 'setSort' | 'view' | 'setView' +>): React.ReactNode { + const {_} = useLingui() + return ( + + + {({props: {onPress, ...props}}) => ( + + )} + + + + Show replies as + + + { + setView('linear') + }}> + + Linear + + + + { + setView('tree') + }}> + + Threaded + + + + + + + Reply sorting + + + { + setSort('top') + }}> + + Top replies first + + + + { + setSort('oldest') + }}> + + Oldest replies first + + + + { + setSort('newest') + }}> + + Newest replies first + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadError.tsx b/src/screens/PostThread/components/ThreadError.tsx new file mode 100644 index 0000000000..e1ca23cf97 --- /dev/null +++ b/src/screens/PostThread/components/ThreadError.tsx @@ -0,0 +1,89 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useCleanError} from '#/lib/hooks/useCleanError' +import {OUTER_SPACE} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +export function ThreadError({ + error, + onRetry, +}: { + error: Error + onRetry: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + + const {title, message} = useMemo(() => { + let title = _(msg`Error loading post`) + let message = _(msg`Something went wrong. Please try again in a moment.`) + + const {raw, clean} = cleanError(error) + + if (error.message.startsWith('Post not found')) { + title = _(msg`Post not found`) + message = clean || raw || message + } + + return {title, message} + }, [_, error, cleanError]) + + return ( + + + + + + {title} + + + {message} + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx new file mode 100644 index 0000000000..23a92bc286 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -0,0 +1,713 @@ +import {memo, useCallback, useMemo} from 'react' +import {type GestureResponderEvent, Text as RNText, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useActorStatus} from '#/lib/actor-status' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {niceDate} from '#/lib/strings/time' +import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' +import {logger} from '#/logger' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {useLanguagePrefs} from '#/state/preferences' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {type PostSource} from '#/state/unstable-post-source' +import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' +import {formatCount} from '#/view/com/util/numeric/format' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {colors} from '#/components/Admonition' +import {Button} from '#/components/Button' +import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {InlineLinkText, Link} from '#/components/Link' +import {ContentHider} from '#/components/moderation/ContentHider' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {type AppModerationCause} from '#/components/Pills' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' +import {PostControls} from '#/components/PostControls' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import * as Prompt from '#/components/Prompt' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' +import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' +import {WhoCanReply} from '#/components/WhoCanReply' +import * as bsky from '#/types/bsky' + +export function ThreadItemAnchor({ + item, + onPostSuccess, + threadgateRecord, + postSource, +}: { + item: Extract + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record + postSource?: PostSource +}) { + const postShadow = usePostShadow(item.value.post) + const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri + const isRoot = threadRootUri === item.uri + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { + const t = useTheme() + + return ( + <> + + + + + + + + + Post has been deleted + + + + + ) +} + +function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { + const t = useTheme() + + return !isRoot ? ( + + + + + + ) : null +} + +const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ + item, + isRoot, + postShadow, + onPostSuccess, + threadgateRecord, + postSource, +}: { + item: Extract + isRoot: boolean + postShadow: Shadow + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record + postSource?: PostSource +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const {openComposer} = useOpenComposer() + const {currentAccount, hasSession} = useSession() + const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) + + const post = postShadow + const record = item.value.post.record + const moderation = item.moderation + const authorShadow = useProfileShadow(post.author) + const {isActive: live} = useActorStatus(post.author) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + + const threadRootUri = record.reply?.root?.uri || post.uri + const authorHref = makeProfileLink(post.author) + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did + + const likesHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + }, [post.uri, post.author]) + const repostsHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') + }, [post.uri, post.author]) + const quotesHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') + }, [post.uri, post.author]) + + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( + rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', + ) + const showFollowButton = + currentAccount?.did !== post.author.did && !onlyFollowersCanReply + + const viaRepost = useMemo(() => { + const reason = postSource?.post.reason + + if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { + return { + uri: reason.uri, + cid: reason.cid, + } + } + }, [postSource]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + }, [ + openComposer, + post, + record, + onPostSuccess, + moderation, + postSource, + feedFeedback, + ]) + + const onOpenAuthor = () => { + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughAuthor', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + } + + const onOpenEmbed = () => { + if (postSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#clickthroughEmbed', + feedContext: postSource.post.feedContext, + reqId: postSource.post.reqId, + }) + } + } + + return ( + <> + + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + + + {sanitizeHandle(post.author.handle, '@')} + + + + + {showFollowButton && ( + + + + )} + + + + + + {richText?.text ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + {formatCount(i18n, post.repostCount)} + {' '} + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + {formatCount(i18n, post.quoteCount)} + {' '} + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + {formatCount(i18n, post.likeCount)} + {' '} + + + + ) : null} + + ) : null} + + + + + + + + + ) +}) + +function ExpandedPostDetails({ + post, + isThreadAuthor, +}: { + post: Extract['value']['post'] + isThreadAuthor: boolean +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const openLink = useOpenLink() + const langPrefs = useLanguagePrefs() + + const translatorUrl = getTranslatorLink( + post.record?.text || '', + langPrefs.primaryLanguage, + ) + const needsTranslation = useMemo( + () => + Boolean( + langPrefs.primaryLanguage && + !isPostInLanguage(post, [langPrefs.primaryLanguage]), + ), + [post, langPrefs.primaryLanguage], + ) + + const onTranslatePress = useCallback( + (e: GestureResponderEvent) => { + e.preventDefault() + openLink(translatorUrl, true) + + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + logger.metric('translate', { + sourceLanguages: post.record.langs ?? [], + targetLanguage: langPrefs.primaryLanguage, + textLength: post.record.text.length, + }) + } + + return false + }, + [openLink, translatorUrl, langPrefs, post], + ) + + return ( + + + + + {niceDate(i18n, post.indexedAt)} + + + {needsTranslation && ( + <> + + · + + + + Translate + + + )} + + + ) +} + +function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() + const {_, i18n} = useLingui() + const control = Prompt.usePromptControl() + + const indexedAt = new Date(post.indexedAt) + const createdAt = bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ? new Date(post.record.createdAt) + : new Date(post.indexedAt) + + // backdated if createdAt is 24 hours or more before indexedAt + const isBackdated = + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 + + if (!isBackdated) return null + + const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light + + return ( + <> + + + + + Archived post + + + + This post claims to have been created on{' '} + {niceDate(i18n, createdAt)}, + but was first seen by Bluesky on{' '} + {niceDate(i18n, indexedAt)}. + + + + + Bluesky cannot confirm the authenticity of the claimed date. + + + + {}} /> + + + + ) +} + +function getThreadAuthor( + post: AppBskyFeedDefs.PostView, + record: AppBskyFeedPost.Record, +): string { + if (!record.reply) { + return post.author.did + } + try { + return new AtUri(record.reply.root.uri).host + } catch { + return '' + } +} + +export function ThreadItemAnchorSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx new file mode 100644 index 0000000000..c8477e211f --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx @@ -0,0 +1,32 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {atoms as a, useTheme} from '#/alf' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' + +export function ThreadItemAnchorNoUnauthenticated() { + const t = useTheme() + + return ( + + + + + + + + + + + + + + + You must sign in to view this post. + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx new file mode 100644 index 0000000000..4337397f84 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -0,0 +1,401 @@ +import {memo, type ReactNode, useCallback, useMemo, useState} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useActorStatus} from '#/lib/actor-status' +import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {PostMeta} from '#/view/com/util/PostMeta' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' +import {type AppModerationCause} from '#/components/Pills' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' +import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' +import {PostControls} from '#/components/PostControls' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +export type ThreadItemPostProps = { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +} + +export function ThreadItemPost({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: ThreadItemPostProps) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemPostDeleted({ + item, + overrides, +}: Pick) { + const t = useTheme() + + return ( + + + + + + + + + Post has been deleted + + + + + + ) +} + +const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ + item, + overrides, + children, +}: Pick & { + children: ReactNode +}) { + const t = useTheme() + const showTopBorder = + !item.ui.showParentReplyLine && overrides?.topBorder !== true + + return ( + + {children} + + ) +}) + +/** + * Provides some space between posts as well as contains the reply line + */ +const ThreadItemPostParentReplyLine = memo( + function ThreadItemPostParentReplyLine({ + item, + }: Pick) { + const t = useTheme() + return ( + + + {item.ui.showParentReplyLine && ( + + )} + + + ) + }, +) + +const ThreadItemPostInner = memo(function ThreadItemPostInner({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: ThreadItemPostProps & { + postShadow: Shadow +}) { + const t = useTheme() + const {openComposer} = useOpenComposer() + const {currentAccount} = useSession() + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const [limitLines, setLimitLines] = useState( + () => countLines(richText?.text) >= MAX_POST_LINES, + ) + const threadRootUri = record.reply?.root?.uri || post.uri + const postHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + }, [openComposer, post, record, onPostSuccess, moderation]) + + const onPressShowMore = useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + const {isActive: live} = useActorStatus(post.author) + + return ( + + + + + + + + + + {(item.ui.showChildReplyLine || + item.ui.precedesChildReadMore) && ( + + )} + + + + + + + {richText?.text ? ( + <> + + {limitLines && ( + + )} + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + ) +}) + +function SubtleHover({children}: {children: ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} + +export function ThreadItemPostSkeleton({index}: {index: number}) { + const even = index % 2 === 0 + return ( + + + + + + + + + + + + {even ? ( + <> + + + + ) : ( + + )} + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx new file mode 100644 index 0000000000..552d8f813e --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx @@ -0,0 +1,74 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' + +export function ThreadItemPostNoUnauthenticated({ + item, +}: { + item: Extract +}) { + const t = useTheme() + + return ( + + + + {item.ui.showParentReplyLine && ( + + )} + + + + + + + + + You must sign in to view this post. + + + + {item.ui.showChildReplyLine && ( + + )} + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostTombstone.tsx b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx new file mode 100644 index 0000000000..4f1ab450bb --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx @@ -0,0 +1,55 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Text} from '#/components/Typography' + +export type ThreadItemPostTombstoneProps = { + type: 'not-found' | 'blocked' +} + +export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) { + const t = useTheme() + const {_} = useLingui() + const {copy, Icon} = useMemo(() => { + switch (type) { + case 'blocked': + return {copy: _(msg`Post blocked`), Icon: PersonXIcon} + case 'not-found': + default: + return {copy: _(msg`Post not found`), Icon: TrashIcon} + } + }, [_, type]) + + return ( + + + + + + + {copy} + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx new file mode 100644 index 0000000000..22ae633951 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx @@ -0,0 +1,107 @@ +import {memo} from 'react' +import {View} from 'react-native' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + type PostThreadParams, + type ThreadItem, +} from '#/state/queries/usePostThread' +import { + LINEAR_AVI_WIDTH, + REPLY_LINE_WIDTH, + TREE_AVI_WIDTH, + TREE_INDENT, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const ThreadItemReadMore = memo(function ThreadItemReadMore({ + item, + view, +}: { + item: Extract + view: PostThreadParams['view'] +}) { + const t = useTheme() + const {_} = useLingui() + const isTreeView = view === 'tree' + const indent = Math.max(0, item.depth - 1) + + const spacers = isTreeView + ? Array.from(Array(indent)).map((_, n: number) => { + const isSkipped = item.skippedIndentIndices.has(n) + return ( + + ) + }) + : null + + return ( + + {spacers} + + + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + <> + + + + Read {item.moreReplies} more{' '} + + + + + ) + }} + + + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx new file mode 100644 index 0000000000..da18a19e90 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx @@ -0,0 +1,89 @@ +import {memo} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {type ThreadItem} from '#/state/queries/usePostThread' +import { + LINEAR_AVI_WIDTH, + OUTER_SPACE, + REPLY_LINE_WIDTH, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({ + item, +}: { + item: Extract +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + {({hovered, pressed}) => { + const interacted = hovered || pressed + return ( + + + + + + + Continue thread... + + + + + + + ) + }} + + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReplyComposer.tsx b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx new file mode 100644 index 0000000000..d93612be8e --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx @@ -0,0 +1,20 @@ +import {View} from 'react-native' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as Skele from '#/components/Skeleton' + +export function ThreadItemReplyComposerSkeleton() { + const t = useTheme() + const {gtMobile} = useBreakpoints() + + if (!gtMobile) return null + + return ( + + + + + + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx new file mode 100644 index 0000000000..e418375b65 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx @@ -0,0 +1,59 @@ +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Text} from '#/components/Typography' + +export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { + const {_} = useLingui() + const t = useTheme() + const label = _(msg`Show more replies`) + + return ( + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx new file mode 100644 index 0000000000..a8ffb76f46 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -0,0 +1,450 @@ +import {memo, useCallback, useMemo, useState} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import { + POST_TOMBSTONE, + type Shadow, + usePostShadow, +} from '#/state/cache/post-shadow' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {PostMeta} from '#/view/com/util/PostMeta' +import { + OUTER_SPACE, + REPLY_LINE_WIDTH, + TREE_AVI_WIDTH, + TREE_INDENT, +} from '#/screens/PostThread/const' +import {atoms as a, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' +import {type AppModerationCause} from '#/components/Pills' +import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' +import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' +import {PostControls} from '#/components/PostControls' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +/** + * Mimic the space in PostMeta + */ +const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap + +export function ThreadItemTreePost({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function ThreadItemTreePostDeleted({ + item, +}: { + item: Extract +}) { + const t = useTheme() + return ( + + + + + + Post has been deleted + + + {item.ui.isLastChild && !item.ui.precedesChildReadMore && ( + + )} + + + ) +} + +const ThreadItemTreePostOuterWrapper = memo( + function ThreadItemTreePostOuterWrapper({ + item, + children, + }: { + item: Extract + children: React.ReactNode + }) { + const t = useTheme() + const indents = Math.max(0, item.ui.indent - 1) + + return ( + + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) + }, +) + +const ThreadItemTreePostInnerWrapper = memo( + function ThreadItemTreePostInnerWrapper({ + item, + children, + }: { + item: Extract + children: React.ReactNode + }) { + const t = useTheme() + return ( + + {item.ui.indent > 1 && ( + + )} + {children} + + ) + }, +) + +const ThreadItemTreeReplyChildReplyLine = memo( + function ThreadItemTreeReplyChildReplyLine({ + item, + }: { + item: Extract + }) { + const t = useTheme() + return ( + + {item.ui.showChildReplyLine && ( + + )} + + ) + }, +) + +const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + postShadow: Shadow + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode { + const {openComposer} = useOpenComposer() + const {currentAccount} = useSession() + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const [limitLines, setLimitLines] = useState( + () => countLines(richText?.text) >= MAX_POST_LINES, + ) + const threadRootUri = record.reply?.root?.uri || post.uri + const postHref = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) + const isControlledByViewer = + new AtUri(threadRootUri).host === currentAccount?.did + return isControlledByViewer && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: currentAccount?.did}, + priority: 6, + }, + ] + : [] + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) + + const onPressReply = useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + }, [openComposer, post, record, onPostSuccess, moderation]) + + const onPressShowMore = useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + return ( + + + + + + + + + + + + {richText?.text ? ( + <> + + {limitLines && ( + + )} + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + + + ) +}) + +function SubtleHover({children}: {children: React.ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} + +export function ThreadItemTreePostSkeleton({index}: {index: number}) { + const t = useTheme() + const even = index % 2 === 0 + return ( + + + + + + + + + + + + {even ? ( + <> + + + + ) : ( + + )} + + + + + + + + + + + + + ) +} diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts new file mode 100644 index 0000000000..cf559ac4e7 --- /dev/null +++ b/src/screens/PostThread/const.ts @@ -0,0 +1,7 @@ +import {tokens} from '#/alf' + +export const TREE_INDENT = tokens.space.lg +export const TREE_AVI_WIDTH = 24 +export const LINEAR_AVI_WIDTH = 42 +export const REPLY_LINE_WIDTH = 2 +export const OUTER_SPACE = tokens.space.lg diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx new file mode 100644 index 0000000000..a4f94851ad --- /dev/null +++ b/src/screens/PostThread/index.tsx @@ -0,0 +1,577 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {useWindowDimensions, View} from 'react-native' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {Trans} from '@lingui/macro' + +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useFeedFeedback} from '#/state/feed-feedback' +import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' +import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' +import {useSession} from '#/state/session' +import {type OnPostSuccessData} from '#/state/shell/composer' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useUnstablePostSource} from '#/state/unstable-post-source' +import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' +import {List, type ListMethods} from '#/view/com/util/List' +import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' +import {ThreadError} from '#/screens/PostThread/components/ThreadError' +import { + ThreadItemAnchor, + ThreadItemAnchorSkeleton, +} from '#/screens/PostThread/components/ThreadItemAnchor' +import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' +import { + ThreadItemPost, + ThreadItemPostSkeleton, +} from '#/screens/PostThread/components/ThreadItemPost' +import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' +import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' +import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' +import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' +import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' +import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' +import { + ThreadItemTreePost, + ThreadItemTreePostSkeleton, +} from '#/screens/PostThread/components/ThreadItemTreePost' +import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' +import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' + +const PARENT_CHUNK_SIZE = 5 +const CHILDREN_CHUNK_SIZE = 50 + +export function PostThread({uri}: {uri: string}) { + const {gtMobile} = useBreakpoints() + const {hasSession} = useSession() + const initialNumToRender = useInitialNumToRender() // TODO + const {height: windowHeight} = useWindowDimensions() + const anchorPostSource = useUnstablePostSource(uri) + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) + + /* + * One query to rule them all + */ + const thread = usePostThread({anchor: uri}) + const anchor = useMemo(() => { + for (const item of thread.data.items) { + if (item.type === 'threadPost' && item.depth === 0) { + return item + } + } + return + }, [thread.data.items]) + + const {openComposer} = useOpenComposer() + const optimisticOnPostReply = useCallback( + (payload: OnPostSuccessData) => { + if (payload) { + const {replyToUri, posts} = payload + if (replyToUri && posts.length) { + thread.actions.insertReplies(replyToUri, posts) + } + } + }, + [thread], + ) + const onReplyToAnchor = useCallback(() => { + if (anchor?.type !== 'threadPost') { + return + } + const post = anchor.value.post + openComposer({ + replyTo: { + uri: anchor.uri, + cid: post.cid, + text: post.record.text, + author: post.author, + embed: post.embed, + moderation: anchor.moderation, + }, + onPostSuccess: optimisticOnPostReply, + }) + + if (anchorPostSource) { + feedFeedback.sendInteraction({ + item: post.uri, + event: 'app.bsky.feed.defs#interactionReply', + feedContext: anchorPostSource.post.feedContext, + reqId: anchorPostSource.post.reqId, + }) + } + }, [ + anchor, + openComposer, + optimisticOnPostReply, + anchorPostSource, + feedFeedback, + ]) + + const isRoot = !!anchor && anchor.value.post.record.reply === undefined + const canReply = !anchor?.value.post?.viewer?.replyDisabled + const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) + const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) + const totalParentCount = useRef(0) // recomputed below + const totalChildrenCount = useRef(thread.data.items.length) // recomputed below + const listRef = useRef(null) + const anchorRef = useRef(null) + const headerRef = useRef(null) + + /* + * On a cold load, parents are not prepended until the anchor post has + * rendered as the first item in the list. This gives us a consistent + * reference point for which to pin the anchor post to the top of the screen. + * + * We simulate a cold load any time the user changes the view or sort params + * so that this handling is consistent. + * + * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives + * us this for free, since the anchor post is the first item in the list. + * + * On web, `onContentSizeChange` is used to get ahead of next paint and handle + * this scrolling. + */ + const [deferParents, setDeferParents] = useState(true) + /** + * Used to flag whether we should scroll to the anchor post. On a cold load, + * this is always true. And when a user changes thread parameters, we also + * manually set this to true. + */ + const shouldHandleScroll = useRef(true) + /** + * Called any time the content size of the list changes, _just_ before paint. + * + * We want this to fire every time we change params (which will reset + * `deferParents` via `onLayout` on the anchor post, due to the key change), + * or click into a new post (which will result in a fresh `deferParents` + * hook). + * + * The result being: any intentional change in view by the user will result + * in the anchor being pinned as the first item. + */ + const onContentSizeChangeWebOnly = web(() => { + const list = listRef.current + const anchor = anchorRef.current as any as Element + const header = headerRef.current as any as Element + + if (list && anchor && header && shouldHandleScroll.current) { + const anchorOffsetTop = anchor.getBoundingClientRect().top + const headerHeight = header.getBoundingClientRect().height + + /* + * `deferParents` is `true` on a cold load, and always reset to + * `true` when params change via `prepareForParamsUpdate`. + * + * On a cold load or a push to a new post, on the first pass of this + * logic, the anchor post is the first item in the list. Therefore + * `anchorOffsetTop - headerHeight` will be 0. + * + * When a user changes thread params, on the first pass of this logic, + * the anchor post may not move (if there are no parents above it), or it + * may have gone off the screen above, because of the sudden lack of + * parents due to `deferParents === true`. This negative value (minus + * `headerHeight`) will result in a _negative_ `offset` value, which will + * scroll the anchor post _down_ to the top of the screen. + * + * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user + * changes params, the anchor post's offset will actually be equivalent + * to the `headerHeight` because of how the DOM is stacked on web. + * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, + * which means the first pass in this case will result in no scroll. + * + * Then, once parents are prepended, this will fire again. Now, the + * `anchorOffsetTop` will be positive, which minus the header height, + * will give us a _positive_ offset, which will scroll the anchor post + * back _up_ to the top of the screen. + */ + list.scrollToOffset({ + offset: anchorOffsetTop - headerHeight, + }) + + /* + * After the second pass, `deferParents` will be `false`, and we need + * to ensure this doesn't run again until scroll handling is requested + * again via `shouldHandleScroll.current === true` and a params + * change via `prepareForParamsUpdate`. + * + * The `isRoot` here is needed because if we're looking at the anchor + * post, this handler will not fire after `deferParents` is set to + * `false`, since there are no parents to render above it. In this case, + * we want to make sure `shouldHandleScroll` is set to `false` so that + * subsequent size changes unrelated to a params change (like pagination) + * do not affect scroll. + */ + if (!deferParents || isRoot) shouldHandleScroll.current = false + } + }) + + /** + * Ditto the above, but for native. + */ + const onContentSizeChangeNativeOnly = native(() => { + const list = listRef.current + const anchor = anchorRef.current + + if (list && anchor && shouldHandleScroll.current) { + /* + * `prepareForParamsUpdate` is called any time the user changes thread params like + * `view` or `sort`, which sets `deferParents(true)` and resets the + * scroll to the top of the list. However, there is a split second + * where the top of the list is wherever the parents _just were_. So if + * there were parents, the anchor is not at the top of the list just + * prior to this handler being called. + * + * Once this handler is called, the anchor post is the first item in + * the list (because of `deferParents` being `true`), and so we can + * synchronously scroll the list back to the top of the list (which is + * 0 on native, no need to handle `headerHeight`). + */ + list.scrollToOffset({ + animated: false, + offset: 0, + }) + + /* + * After this first pass, `deferParents` will be `false`, and those + * will render in. However, the anchor post will retain its position + * because of `maintainVisibleContentPosition` handling on native. So we + * don't need to let this handler run again, like we do on web. + */ + shouldHandleScroll.current = false + } + }) + + /** + * Called any time the user changes thread params, such as `view` or `sort`. + * Prepares the UI for repositioning of the scroll so that the anchor post is + * always at the top after a params change. + * + * No need to handle max parents here, deferParents will handle that and we + * want it to re-render with the same items above the anchor. + */ + const prepareForParamsUpdate = useCallback(() => { + /** + * Truncate list so that anchor post is the first item in the list. Manual + * scroll handling on web is predicated on this, and on native, this allows + * `maintainVisibleContentPosition` to do its thing. + */ + setDeferParents(true) + // reset this to a lower value for faster re-render + setMaxChildrenCount(CHILDREN_CHUNK_SIZE) + // set flag + shouldHandleScroll.current = true + }, [setDeferParents, setMaxChildrenCount]) + + const setSortWrapped = useCallback( + (sort: string) => { + prepareForParamsUpdate() + thread.actions.setSort(sort) + }, + [thread, prepareForParamsUpdate], + ) + + const setViewWrapped = useCallback( + (view: ThreadViewOption) => { + prepareForParamsUpdate() + thread.actions.setView(view) + }, + [thread, prepareForParamsUpdate], + ) + + const onStartReached = () => { + if (thread.state.isFetching) return + // can be true after `prepareForParamsUpdate` is called + if (deferParents) return + // prevent any state mutations if we know we're done + if (maxParentCount >= totalParentCount.current) return + setMaxParentCount(n => n + PARENT_CHUNK_SIZE) + } + + const onEndReached = () => { + if (thread.state.isFetching) return + // can be true after `prepareForParamsUpdate` is called + if (deferParents) return + // prevent any state mutations if we know we're done + if (maxChildrenCount >= totalChildrenCount.current) return + setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) + } + + const slices = useMemo(() => { + const results: ThreadItem[] = [] + + if (!thread.data.items.length) return results + + /* + * Pagination hack, tracks the # of items below the anchor post. + */ + let childrenCount = 0 + + for (let i = 0; i < thread.data.items.length; i++) { + const item = thread.data.items[i] + /* + * Need to check `depth`, since not found or blocked posts are not + * `threadPost`s, but still have `depth`. + */ + const hasDepth = 'depth' in item + + /* + * Handle anchor post. + */ + if (hasDepth && item.depth === 0) { + results.push(item) + + // Recalculate total parents current index. + totalParentCount.current = i + // Recalculate total children using (length - 1) - current index. + totalChildrenCount.current = thread.data.items.length - 1 - i + + /* + * Walk up the parents, limiting by `maxParentCount` + */ + if (!deferParents) { + const start = i - 1 + if (start >= 0) { + const limit = Math.max(0, start - maxParentCount) + for (let pi = start; pi >= limit; pi--) { + results.unshift(thread.data.items[pi]) + } + } + } + } else { + // ignore any parent items + if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue + // can exit early if we've reached the max children count + if (childrenCount > maxChildrenCount) break + + results.push(item) + childrenCount++ + } + } + + return results + }, [thread, deferParents, maxParentCount, maxChildrenCount]) + + const isTombstoneView = useMemo(() => { + if (slices.length > 1) return false + return slices.every( + s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', + ) + }, [slices]) + + const renderItem = useCallback( + ({item, index}: {item: ThreadItem; index: number}) => { + if (item.type === 'threadPost') { + if (item.depth < 0) { + return ( + + ) + } else if (item.depth === 0) { + return ( + /* + * Keep this view wrapped so that the anchor post is always index 0 + * in the list and `maintainVisibleContentPosition` can do its + * thing. + */ + + setDeferParents(false)} + /> + + + ) + } else { + if (thread.state.view === 'tree') { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } else { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } + } + } else if (item.type === 'threadPostNoUnauthenticated') { + if (item.depth < 0) { + return + } else if (item.depth === 0) { + return + } + } else if (item.type === 'readMore') { + return ( + + ) + } else if (item.type === 'readMoreUp') { + return + } else if (item.type === 'threadPostBlocked') { + return + } else if (item.type === 'threadPostNotFound') { + return + } else if (item.type === 'replyComposer') { + return ( + + {gtMobile && ( + + )} + + ) + } else if (item.type === 'showOtherReplies') { + return + } else if (item.type === 'skeleton') { + if (item.item === 'anchor') { + return + } else if (item.item === 'reply') { + if (thread.state.view === 'linear') { + return + } else { + return + } + } else if (item.item === 'replyComposer') { + return + } + } + return null + }, + [ + thread, + optimisticOnPostReply, + onReplyToAnchor, + gtMobile, + anchorPostSource, + ], + ) + + return ( + <> + + + + + Post + + + + + + + + {thread.state.error ? ( + + ) : ( + + } + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + )} + + {!gtMobile && canReply && hasSession && ( + + )} + + ) +} + +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const {footerHeight} = useShellLayout() + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: footerHeight.get(), + } + }) + + return ( + + + + ) +} + +const keyExtractor = (item: ThreadItem) => { + return item.key +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index a8bf656921..cfbf430c45 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -1,11 +1,11 @@ import {View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles' import {isIOS} from '#/platform/detection' -import {Shadow} from '#/state/cache/types' +import {type Shadow} from '#/state/cache/types' import {atoms as a, useTheme, web} from '#/alf' import {NewskieDialog} from '#/components/NewskieDialog' import {Text} from '#/components/Typography' diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 9777c8cc78..53585c0947 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,9 +1,11 @@ -import React, {memo, useEffect} from 'react' +import {memo, useCallback, useEffect, useMemo} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import { +import Animated, { + measure, type MeasuredDimensions, runOnJS, runOnUI, + useAnimatedRef, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' @@ -14,7 +16,6 @@ import {useNavigation} from '@react-navigation/native' import {useActorStatus} from '#/lib/actor-status' import {BACK_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' -import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef' import {type NavigationProp} from '#/lib/routes/types' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' @@ -59,9 +60,9 @@ let ProfileHeaderShell = ({ const playHaptic = useHaptics() const liveStatusControl = useDialogControl() - const aviRef = useHandleRef() + const aviRef = useAnimatedRef() - const onPressBack = React.useCallback(() => { + const onPressBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() } else { @@ -69,7 +70,7 @@ let ProfileHeaderShell = ({ } }, [navigation]) - const _openLightbox = React.useCallback( + const _openLightbox = useCallback( (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ @@ -92,7 +93,7 @@ let ProfileHeaderShell = ({ [openLightbox], ) - const isMe = React.useMemo( + const isMe = useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) @@ -109,7 +110,7 @@ let ProfileHeaderShell = ({ } }, [live.isActive, profile.did]) - const onPressAvi = React.useCallback(() => { + const onPressAvi = useCallback(() => { if (live.isActive) { playHaptic('Light') logger.metric( @@ -122,10 +123,9 @@ let ProfileHeaderShell = ({ const modui = moderation.ui('avatar') const avatar = profile.avatar if (avatar && !(modui.blur && modui.noOverride)) { - const aviHandle = aviRef.current runOnUI(() => { 'worklet' - const rect = measureHandle(aviHandle) + const rect = measure(aviRef) runOnJS(_openLightbox)(avatar, rect) })() } @@ -223,7 +223,7 @@ let ProfileHeaderShell = ({ styles.avi, profile.associated?.labeler && styles.aviLabeler, ]}> - + {live.isActive && } - + diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 7c9e2a25dc..92eed25aa4 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -301,19 +301,19 @@ export function Explore({ const onPTR = useCallback(async () => { setIsPTR(true) await Promise.all([ - await qc.resetQueries({ + qc.resetQueries({ queryKey: createGetTrendsQueryKey(), }), - await qc.resetQueries({ + qc.resetQueries({ queryKey: createSuggestedStarterPacksQueryKey(), }), - await qc.resetQueries({ + qc.resetQueries({ queryKey: [getSuggestedUsersQueryKeyRoot], }), - await qc.resetQueries({ + qc.resetQueries({ queryKey: [useActorSearchPaginatedQueryKeyRoot], }), - await qc.resetQueries({ + qc.resetQueries({ queryKey: createGetSuggestedFeedsQueryKey(), }), ]) diff --git a/src/screens/Settings/SettingsInterests.tsx b/src/screens/Settings/InterestsSettings.tsx similarity index 93% rename from src/screens/Settings/SettingsInterests.tsx rename to src/screens/Settings/InterestsSettings.tsx index 42259e9b68..e3b5fcb084 100644 --- a/src/screens/Settings/SettingsInterests.tsx +++ b/src/screens/Settings/InterestsSettings.tsx @@ -2,9 +2,11 @@ import {useMemo, useState} from 'react' import {type TextStyle, View, type ViewStyle} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useQueryClient} from '@tanstack/react-query' import debounce from 'lodash.debounce' +import {type CommonNavigatorParams} from '#/lib/routes/types' import { preferencesQueryKey, usePreferencesQuery, @@ -24,7 +26,8 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -export function SettingsInterests() { +type Props = NativeStackScreenProps +export function InterestsSettingsScreen({}: Props) { const t = useTheme() const gutters = useGutters(['base']) const {data: preferences} = usePreferencesQuery() @@ -110,13 +113,9 @@ function Inner({ }, ) await Promise.all([ - await qc.resetQueries({ - queryKey: createSuggestedStarterPacksQueryKey(), - }), - await qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}), - await qc.resetQueries({ - queryKey: createGetSuggestedUsersQueryKey({}), - }), + qc.resetQueries({queryKey: createSuggestedStarterPacksQueryKey()}), + qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}), + qc.resetQueries({queryKey: createGetSuggestedUsersQueryKey({})}), ]) Toast.show( diff --git a/src/screens/Settings/LegacyNotificationSettings.tsx b/src/screens/Settings/LegacyNotificationSettings.tsx new file mode 100644 index 0000000000..a9ef5d9831 --- /dev/null +++ b/src/screens/Settings/LegacyNotificationSettings.tsx @@ -0,0 +1,21 @@ +import {useCallback} from 'react' +import {useFocusEffect} from '@react-navigation/native' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'LegacyNotificationSettings' +> +export function LegacyNotificationSettingsScreen({navigation}: Props) { + useFocusEffect( + useCallback(() => { + navigation.replace('NotificationSettings') + }, [navigation]), + ) + + return null +} diff --git a/src/screens/Settings/NotificationSettings.tsx b/src/screens/Settings/NotificationSettings.tsx deleted file mode 100644 index ebb230c2ca..0000000000 --- a/src/screens/Settings/NotificationSettings.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import {Text} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' -import {useNotificationSettingsMutation} from '#/state/queries/notifications/settings' -import {atoms as a} from '#/alf' -import {Admonition} from '#/components/Admonition' -import {Error} from '#/components/Error' -import * as Toggle from '#/components/forms/Toggle' -import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' -import * as Layout from '#/components/Layout' -import {Loader} from '#/components/Loader' -import * as SettingsList from './components/SettingsList' - -type Props = NativeStackScreenProps -export function NotificationSettingsScreen({}: Props) { - const {_} = useLingui() - - const { - data, - isError: isQueryError, - refetch, - } = useNotificationFeedQuery({ - filter: 'all', - }) - const serverPriority = data?.pages.at(0)?.priority - - const { - mutate: onChangePriority, - isPending: isMutationPending, - variables, - } = useNotificationSettingsMutation() - - const priority = isMutationPending - ? variables[0] === 'enabled' - : serverPriority - - return ( - - - - - - Notification Settings - - - - - - {isQueryError ? ( - - ) : ( - - - - - Notification filters - - - - - Enable priority notifications - - {!data ? : } - - - - - - - Experimental: When this - preference is enabled, you'll only receive reply and quote - notifications from users you follow. We'll continue to add - more controls here over time. - - - - - )} - - - ) -} diff --git a/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx new file mode 100644 index 0000000000..f726ab558a --- /dev/null +++ b/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx @@ -0,0 +1,60 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'LikeNotificationSettings' +> +export function LikeNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Likes} + subtitleText={ + Get notifications when people like your posts. + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx new file mode 100644 index 0000000000..c72e8c7578 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx @@ -0,0 +1,65 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon} from '#/components/icons/Heart2' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'LikesOnRepostsNotificationSettings' +> +export function LikesOnRepostsNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Likes of your reposts} + subtitleText={ + + Get notifications when people like posts that you've reposted. + + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx new file mode 100644 index 0000000000..0a770157e9 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx @@ -0,0 +1,63 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'MentionNotificationSettings' +> +export function MentionNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Mentions} + subtitleText={ + Get notifications when people mention you. + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx new file mode 100644 index 0000000000..a0fe65ecf6 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx @@ -0,0 +1,68 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'MiscellaneousNotificationSettings' +> +export function MiscellaneousNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Everything else} + subtitleText={ + + Notifications for everything else, such as when someone joins + via one of your starter packs. + + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx new file mode 100644 index 0000000000..dd603a52f5 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx @@ -0,0 +1,63 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'NewFollowerNotificationSettings' +> +export function NewFollowerNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + New followers} + subtitleText={ + Get notifications when people follow you. + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx new file mode 100644 index 0000000000..afb3df90f5 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx @@ -0,0 +1,60 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'QuoteNotificationSettings' +> +export function QuoteNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Quotes} + subtitleText={ + Get notifications when people quote your posts. + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx new file mode 100644 index 0000000000..b3e7c6cff2 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx @@ -0,0 +1,66 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'ReplyNotificationSettings' +> +export function ReplyNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Replies} + subtitleText={ + + Get notifications when people reply to your posts. + + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx new file mode 100644 index 0000000000..aa9e4e32fa --- /dev/null +++ b/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx @@ -0,0 +1,63 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'RepostNotificationSettings' +> +export function RepostNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Reposts} + subtitleText={ + Get notifications when people repost your posts. + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx new file mode 100644 index 0000000000..13fec61682 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx @@ -0,0 +1,66 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon} from '#/components/icons/Repost' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'RepostsOnRepostsNotificationSettings' +> +export function RepostsOnRepostsNotificationSettingsScreen({}: Props) { + const {data: preferences, isError} = useNotificationSettingsQuery() + + return ( + + + + + + Notifications + + + + + + + + + Reposts of your reposts} + subtitleText={ + + Get notifications when people repost posts that you've + reposted. + + } + /> + + {isError ? ( + + + Failed to load notification settings. + + + ) : ( + + )} + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx b/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx new file mode 100644 index 0000000000..217fc33b95 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx @@ -0,0 +1,34 @@ +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import * as Skele from '#/components/Skeleton' +import {Text} from '#/components/Typography' +import * as SettingsList from '../../components/SettingsList' + +export function ItemTextWithSubtitle({ + titleText, + subtitleText, + bold = false, + showSkeleton = false, +}: { + titleText: React.ReactNode + subtitleText: React.ReactNode + bold?: boolean + showSkeleton?: boolean +}) { + const t = useTheme() + return ( + + + {titleText} + + {showSkeleton ? ( + + ) : ( + + {subtitleText} + + )} + + ) +} diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx new file mode 100644 index 0000000000..487827d66b --- /dev/null +++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx @@ -0,0 +1,201 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {type AppBskyNotificationDefs} from '@atproto/api' +import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky/notification/defs' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGate} from '#/lib/statsig/statsig' +import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' +import {atoms as a, platform, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {Divider} from '../../components/SettingsList' + +export function PreferenceControls({ + name, + syncOthers, + preference, + allowDisableInApp = true, +}: { + name: Exclude + /** + * Keep other prefs in sync with `name`. For use in the "everything else" category + * which groups starterpack joins + verified + unverified notifications into a single toggle. + */ + syncOthers?: Exclude[] + preference?: AppBskyNotificationDefs.Preference | FilterablePreference + allowDisableInApp?: boolean +}) { + const gate = useGate() + + if (!gate('reengagement_features')) return null + + if (!preference) + return ( + + + + ) + + return ( + + ) +} + +export function Inner({ + name, + syncOthers = [], + preference, + allowDisableInApp, +}: { + name: Exclude + syncOthers?: Exclude[] + preference: AppBskyNotificationDefs.Preference | FilterablePreference + allowDisableInApp: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {mutate} = useNotificationSettingsUpdateMutation() + + const channels = useMemo(() => { + const arr = [] + if (preference.list) arr.push('list') + if (preference.push) arr.push('push') + return arr + }, [preference]) + + const onChangeChannels = (change: string[]) => { + const newPreference = { + ...preference, + list: change.includes('list'), + push: change.includes('push'), + } satisfies typeof preference + + mutate({ + [name]: newPreference, + ...Object.fromEntries(syncOthers.map(key => [key, newPreference])), + }) + } + + const onChangeFilter = ([change]: string[]) => { + if (change !== 'all' && change !== 'follows') + throw new Error('Invalid filter') + + const newPreference = { + ...preference, + include: change, + } satisfies typeof preference + + mutate({ + [name]: newPreference, + ...Object.fromEntries(syncOthers.map(key => [key, newPreference])), + }) + } + + return ( + + + + + + Push notifications + + + + {allowDisableInApp && ( + + + In-app notifications + + + + )} + + + {'include' in preference && ( + <> + + + From + + + + + + 0 && t.atoms.text, + a.font_normal, + a.text_md, + ]}> + Everyone + + + + + 0 && t.atoms.text, + a.font_normal, + a.text_md, + ]}> + People I follow + + + + + + )} + + ) +} diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx new file mode 100644 index 0000000000..800493575d --- /dev/null +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -0,0 +1,293 @@ +import {useEffect} from 'react' +import {Linking, View} from 'react-native' +import * as Notification from 'expo-notifications' +import {type AppBskyNotificationDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {useAppState} from '#/lib/hooks/useAppState' +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {isAndroid, isIOS, isWeb} from '#/platform/detection' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' +// import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' +import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' +import { + Heart2_Stroke2_Corner0_Rounded as HeartIcon, + LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon, +} from '#/components/icons/Heart2' +import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' +import { + Repost_Stroke2_Corner2_Rounded as RepostIcon, + RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon, +} from '#/components/icons/Repost' +import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' +import * as Layout from '#/components/Layout' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' + +const RQKEY = ['notification-permissions'] + +type Props = NativeStackScreenProps +export function NotificationSettingsScreen({}: Props) { + const {_} = useLingui() + const queryClient = useQueryClient() + const {data: settings, isError} = useNotificationSettingsQuery() + + const {data: permissions, refetch} = useQuery({ + queryKey: RQKEY, + queryFn: async () => { + if (isWeb) return null + return await Notification.getPermissionsAsync() + }, + }) + + const appState = useAppState() + useEffect(() => { + if (appState === 'active') { + refetch() + } + }, [appState, refetch]) + + const onRequestPermissions = async () => { + if (isWeb) return + if (permissions?.canAskAgain) { + const response = await Notification.requestPermissionsAsync() + queryClient.setQueryData(RQKEY, response) + } else { + if (isAndroid) { + try { + await Linking.sendIntent( + 'android.settings.APP_NOTIFICATION_SETTINGS', + [ + { + key: 'android.provider.extra.APP_PACKAGE', + value: 'xyz.blueskyweb.app', + }, + ], + ) + } catch { + Linking.openSettings() + } + } else if (isIOS) { + Linking.openSettings() + } + } + } + + return ( + + + + + + Notifications + + + + + + + {permissions && !permissions.granted && ( + <> + + + + Enable push notifications + + + + + )} + {isError && ( + + + Failed to load notification settings. + + + )} + + + + Likes} + subtitleText={} + showSkeleton={!settings} + /> + + + + New followers} + subtitleText={} + showSkeleton={!settings} + /> + + + + Replies} + subtitleText={} + showSkeleton={!settings} + /> + + + + Mentions} + subtitleText={} + showSkeleton={!settings} + /> + + + + Quotes} + subtitleText={} + showSkeleton={!settings} + /> + + + + Reposts} + subtitleText={} + showSkeleton={!settings} + /> + + {/* + + + Activity alerts} + subtitleText={ + + } + showSkeleton={!settings} + /> + */} + + + Likes of your reposts} + subtitleText={ + + } + showSkeleton={!settings} + /> + + + + Reposts of your reposts} + subtitleText={ + + } + showSkeleton={!settings} + /> + + + + Everything else} + // technically a bundle of several settings, but since they're set together + // and are most likely in sync we'll just show the state of one of them + subtitleText={ + + } + showSkeleton={!settings} + /> + + + + + + ) +} + +function SettingPreview({ + preference, +}: { + preference?: + | AppBskyNotificationDefs.Preference + | AppBskyNotificationDefs.FilterablePreference +}) { + const {_} = useLingui() + if (!preference) { + return null + } else { + if ('include' in preference) { + if (preference.include === 'all') { + if (preference.list && preference.push) { + return _(msg`In-app, Push, Everyone`) + } else if (preference.list) { + return _(msg`In-app, Everyone`) + } else if (preference.push) { + return _(msg`Push, Everyone`) + } + } else if (preference.include === 'follows') { + if (preference.list && preference.push) { + return _(msg`In-app, Push, People you follow`) + } else if (preference.list) { + return _(msg`In-app, People you follow`) + } else if (preference.push) { + return _(msg`Push, People you follow`) + } + } + } else { + if (preference.list && preference.push) { + return _(msg`In-app, Push`) + } else if (preference.list) { + return _(msg`In-app`) + } else if (preference.push) { + return _(msg`Push`) + } + } + } + + return _(msg`Off`) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 9f36c27acc..e1d1970708 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -16,6 +16,7 @@ import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -36,6 +37,7 @@ import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' @@ -80,6 +82,7 @@ export function SettingsScreen({}: Props) { const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() const [showAccounts, setShowAccounts] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false) + const gate = useGate() return ( @@ -180,6 +183,16 @@ export function SettingsScreen({}: Props) { Moderation + {gate('reengagement_features') && ( + + + + Notifications + + + )} diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index 701d3d9e56..af3cf915f5 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -2,22 +2,156 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import { usePreferencesQuery, useSetThreadViewPreferencesMutation, } from '#/state/queries/preferences' +import { + normalizeSort, + normalizeView, + useThreadPreferences, +} from '#/state/queries/preferences/useThreadPreferences' import {atoms as a, useTheme} from '#/alf' import * as Toggle from '#/components/forms/Toggle' import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' +import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' import * as SettingsList from './components/SettingsList' type Props = NativeStackScreenProps export function ThreadPreferencesScreen({}: Props) { + const gate = useGate() + + return gate('post_threads_v2_unspecced') ? ( + + ) : ( + + ) +} + +export function ThreadPreferencesV2() { + const t = useTheme() + const {_} = useLingui() + const { + sort, + setSort, + view, + setView, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers, + } = useThreadPreferences({save: true}) + + return ( + + + + + + Thread Preferences + + + + + + + + + + Sort replies + + + + Sort replies to the same post by: + + setSort(normalizeSort(values[0]))}> + + + + + Top replies first + + + + + + Oldest replies first + + + + + + Newest replies first + + + + + + + + + + + Prioritize your Follows + + setPrioritizeFollowedUsers(value)} + style={[a.w_full, a.gap_md]}> + + + Show replies by people you follow before all other replies + + + + + + + + + + Tree view + + + setView(normalizeView({treeViewEnabled: value})) + } + style={[a.w_full, a.gap_md]}> + + Show post replies in a threaded tree view + + + + + + + + ) +} + +export function ThreadPreferencesV1() { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx index 81533c58e7..17980172de 100644 --- a/src/screens/Signup/StepInfo/Policies.tsx +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -1,6 +1,6 @@ -import {ReactElement} from 'react' +import {type ReactElement} from 'react' import {View} from 'react-native' -import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx index b522bc906d..39ae578557 100644 --- a/src/screens/StarterPack/StarterPackLandingScreen.tsx +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -5,7 +5,7 @@ import { AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri, - ModerationOpts, + type ModerationOpts, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx index 1ecd038a49..07d744c062 100644 --- a/src/screens/StarterPack/Wizard/State.tsx +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -1,6 +1,6 @@ import React from 'react' -import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' -import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {type GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {msg, plural} from '@lingui/macro' import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' diff --git a/src/screens/Takendown.tsx b/src/screens/Takendown.tsx index ef3e936584..d01903eb5a 100644 --- a/src/screens/Takendown.tsx +++ b/src/screens/Takendown.tsx @@ -3,7 +3,7 @@ import {Modal, View} from 'react-native' import {SystemBars} from 'react-native-edge-to-edge' import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api' +import {type ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' diff --git a/src/screens/VideoFeed/components/Scrubber.tsx b/src/screens/VideoFeed/components/Scrubber.tsx index ef31905263..69e68ec9e3 100644 --- a/src/screens/VideoFeed/components/Scrubber.tsx +++ b/src/screens/VideoFeed/components/Scrubber.tsx @@ -3,13 +3,13 @@ import {View} from 'react-native' import { Gesture, GestureDetector, - NativeGesture, + type NativeGesture, } from 'react-native-gesture-handler' import Animated, { interpolate, runOnJS, runOnUI, - SharedValue, + type SharedValue, useAnimatedReaction, useAnimatedStyle, useSharedValue, @@ -20,11 +20,11 @@ import { useSafeAreaInsets, } from 'react-native-safe-area-context' import {useEventListener} from 'expo' -import {VideoPlayer} from 'expo-video' +import {type VideoPlayer} from 'expo-video' -import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils' import {tokens} from '#/alf' import {atoms as a} from '#/alf' +import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils' import {Text} from '#/components/Typography' // magic number that is roughly the min height of the write reply button diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 8a75751f72..495b3bc622 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -882,7 +882,10 @@ function Overlay({ player={player} seekingAnimationSV={seekingAnimationSV} scrollGesture={scrollGesture}> - + diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 923e5c0000..d7f1eb8b93 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' @@ -149,6 +150,9 @@ function* findPostsInCache( yield node.post } } + for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { + yield post + } for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { yield post } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index a1212d8a29..31bf55d132 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' +import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import type * as bsky from '#/types/bsky' import {castAsShadow, type Shadow} from './types' @@ -167,6 +168,7 @@ function* findProfilesInCache( yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadQueryData(queryClient, did) + yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) } diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 225b495d3f..a718a761d5 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -12,7 +12,7 @@ import throttle from 'lodash.throttle' import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' -import {logger} from '#/logger' +import {Logger} from '#/logger' import { type FeedDescriptor, type FeedPostSliceItem, @@ -20,6 +20,8 @@ import { import {getItemsForFeedback} from '#/view/com/posts/PostFeed' import {useAgent} from './session' +const logger = Logger.create(Logger.Context.FeedFeedback) + export type StateContext = { enabled: boolean onItemSeen: (item: any) => void @@ -89,6 +91,7 @@ export function useFeedFeedback( } sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) throttledFlushAggregatedStats() + logger.debug('flushed') }, [agent, throttledFlushAggregatedStats, feed]) const sendToFeed = useMemo( @@ -141,6 +144,9 @@ export function useFeedFeedback( if (!enabled) { return } + logger.debug('sendInteraction', { + ...interaction, + }) if (!history.current.has(interaction)) { history.current.add(interaction) queue.current.add(toString(interaction)) diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts index 1a6cfb3f2a..589c5b6d3a 100644 --- a/src/state/messages/events/agent.ts +++ b/src/state/messages/events/agent.ts @@ -1,4 +1,4 @@ -import {BskyAgent, ChatBskyConvoGetLog} from '@atproto/api' +import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api' import EventEmitter from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' @@ -9,11 +9,11 @@ import { DEFAULT_POLL_INTERVAL, } from '#/state/messages/events/const' import { - MessagesEventBusDispatch, + type MessagesEventBusDispatch, MessagesEventBusDispatchEvent, MessagesEventBusErrorCode, - MessagesEventBusEvent, - MessagesEventBusParams, + type MessagesEventBusEvent, + type MessagesEventBusParams, MessagesEventBusStatus, } from '#/state/messages/events/types' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 7ebcec4c79..a2cc637450 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -43,13 +43,6 @@ export interface ChangePasswordModal { name: 'change-password' } -export interface LinkWarningModal { - name: 'link-warning' - text: string - href: string - share?: boolean -} - export type Modal = // Account | DeleteAccountModal @@ -67,9 +60,6 @@ export type Modal = | WaitlistModal | InviteCodesModal - // Generic - | LinkWarningModal - const ModalContext = React.createContext<{ isModalActive: boolean activeModals: Modal[] diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts index 2ac42aa328..9661bed1be 100644 --- a/src/state/queries/notifications/settings.ts +++ b/src/state/queries/notifications/settings.ts @@ -1,72 +1,63 @@ -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation, useQueryClient} from '@tanstack/react-query' +import {type AppBskyNotificationDefs} from '@atproto/api' +import {t} from '@lingui/macro' +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' -import {until} from '#/lib/async/until' import {logger} from '#/logger' -import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' -import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' import {useAgent} from '#/state/session' import * as Toast from '#/view/com/util/Toast' -export function useNotificationSettingsMutation() { - const {_} = useLingui() +const RQKEY_ROOT = 'notification-settings' +const RQKEY = [RQKEY_ROOT] + +export function useNotificationSettingsQuery() { + const agent = useAgent() + + return useQuery({ + queryKey: RQKEY, + queryFn: async () => { + const response = await agent.app.bsky.notification.getPreferences() + return response.data.preferences + }, + }) +} +export function useNotificationSettingsUpdateMutation() { const agent = useAgent() const queryClient = useQueryClient() return useMutation({ - mutationFn: async (keys: string[]) => { - const enabled = keys[0] === 'enabled' - - await agent.api.app.bsky.notification.putPreferences({ - priority: enabled, - }) - - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - res => res.data.priority === enabled, - () => agent.api.app.bsky.notification.listNotifications({limit: 1}), + mutationFn: async ( + update: Partial, + ) => { + const response = await agent.app.bsky.notification.putPreferencesV2( + update, ) - - eagerlySetCachedPriority(queryClient, enabled) + return response.data.preferences }, - onError: err => { - logger.error('Failed to save notification preferences', { - safeMessage: err, - }) - Toast.show( - _(msg`Failed to save notification preferences, please try again`), - 'xmark', - ) + onMutate: update => { + optimisticUpdateNotificationSettings(queryClient, update) }, - onSuccess: () => { - Toast.show(_(msg({message: 'Preference saved', context: 'toast'}))) - }, - onSettled: () => { - invalidateCachedUnreadPage() - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('all')}) - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('mentions')}) + onError: e => { + logger.error('Could not update notification settings', {message: e}) + queryClient.invalidateQueries({queryKey: RQKEY}) + Toast.show(t`Could not update notification settings`, 'xmark') }, }) } -function eagerlySetCachedPriority( - queryClient: ReturnType, - enabled: boolean, +function optimisticUpdateNotificationSettings( + queryClient: QueryClient, + update: Partial, ) { - function updateData(old: any) { - if (!old) return old - return { - ...old, - pages: old.pages.map((page: any) => { - return { - ...page, - priority: enabled, - } - }), - } - } - queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData) - queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData) + queryClient.setQueryData( + RQKEY, + (old?: AppBskyNotificationDefs.Preferences) => { + if (!old) return old + return {...old, ...update} + }, + ) } diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts index c1955cc74f..0952a1ad09 100644 --- a/src/state/queries/postgate/util.ts +++ b/src/state/queries/postgate/util.ts @@ -1,9 +1,9 @@ import { - $Typed, + type $Typed, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, - AppBskyFeedDefs, - AppBskyFeedPostgate, + type AppBskyFeedDefs, + type AppBskyFeedPostgate, AtUri, } from '@atproto/api' @@ -113,6 +113,7 @@ export function createEmbedViewRecordFromPost( likeCount: post.likeCount, quoteCount: post.quoteCount, indexedAt: post.indexedAt, + embeds: post.embed ? [post.embed] : [], } } diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts new file mode 100644 index 0000000000..dc3122a72a --- /dev/null +++ b/src/state/queries/preferences/useThreadPreferences.ts @@ -0,0 +1,179 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' +import debounce from 'lodash.debounce' + +import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce' +import {logger} from '#/logger' +import { + usePreferencesQuery, + useSetThreadViewPreferencesMutation, +} from '#/state/queries/preferences' +import {type ThreadViewPreferences} from '#/state/queries/preferences/types' +import {type Literal} from '#/types/utils' + +export type ThreadSortOption = Literal< + AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'], + string +> +export type ThreadViewOption = 'linear' | 'tree' +export type ThreadPreferences = { + isLoaded: boolean + isSaving: boolean + sort: ThreadSortOption + setSort: (sort: string) => void + view: ThreadViewOption + setView: (view: ThreadViewOption) => void + prioritizeFollowedUsers: boolean + setPrioritizeFollowedUsers: (prioritize: boolean) => void +} + +export function useThreadPreferences({ + save, +}: {save?: boolean} = {}): ThreadPreferences { + const {data: preferences} = usePreferencesQuery() + const serverPrefs = preferences?.threadViewPrefs + const once = useCallOnce(OnceKey.PreferencesThread) + + /* + * Create local state representations of server state + */ + const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top')) + const [view, setView] = useState( + normalizeView({ + treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, + }), + ) + const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( + !!serverPrefs?.prioritizeFollowedUsers, + ) + + /** + * If we get a server update, update local state + */ + const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) + const isLoaded = !!prevServerPrefs + if (serverPrefs && prevServerPrefs !== serverPrefs) { + setPrevServerPrefs(serverPrefs) + + /* + * Update + */ + setSort(normalizeSort(serverPrefs.sort)) + setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers) + setView( + normalizeView({ + treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, + }), + ) + + once(() => { + logger.metric('thread:preferences:load', { + sort: serverPrefs.sort, + view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', + prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers, + }) + }) + } + + const userUpdatedPrefs = useRef(false) + const [isSaving, setIsSaving] = useState(false) + const {mutateAsync} = useSetThreadViewPreferencesMutation() + const savePrefs = useMemo(() => { + return debounce(async (prefs: ThreadViewPreferences) => { + try { + setIsSaving(true) + await mutateAsync(prefs) + logger.metric('thread:preferences:update', { + sort: prefs.sort, + view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', + prioritizeFollowedUsers: prefs.prioritizeFollowedUsers, + }) + } catch (e) { + logger.error('useThreadPreferences failed to save', { + safeMessage: e, + }) + } finally { + setIsSaving(false) + } + }, 4e3) + }, [mutateAsync]) + + if (save && userUpdatedPrefs.current) { + savePrefs({ + sort, + prioritizeFollowedUsers, + lab_treeViewEnabled: view === 'tree', + }) + userUpdatedPrefs.current = false + } + + const setSortWrapped = useCallback( + (next: string) => { + userUpdatedPrefs.current = true + setSort(normalizeSort(next)) + }, + [setSort], + ) + const setViewWrapped = useCallback( + (next: ThreadViewOption) => { + userUpdatedPrefs.current = true + setView(next) + }, + [setView], + ) + const setPrioritizeFollowedUsersWrapped = useCallback( + (next: boolean) => { + userUpdatedPrefs.current = true + setPrioritizeFollowedUsers(next) + }, + [setPrioritizeFollowedUsers], + ) + + return useMemo( + () => ({ + isLoaded, + isSaving, + sort, + setSort: setSortWrapped, + view, + setView: setViewWrapped, + prioritizeFollowedUsers, + setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped, + }), + [ + isLoaded, + isSaving, + sort, + setSortWrapped, + view, + setViewWrapped, + prioritizeFollowedUsers, + setPrioritizeFollowedUsersWrapped, + ], + ) +} + +/** + * Migrates user thread preferences from the old sort values to V2 + */ +export function normalizeSort(sort: string): ThreadSortOption { + switch (sort) { + case 'oldest': + return 'oldest' + case 'newest': + return 'newest' + default: + return 'top' + } +} + +/** + * Transforms existing treeViewEnabled preference into a ThreadViewOption + */ +export function normalizeView({ + treeViewEnabled, +}: { + treeViewEnabled: boolean +}): ThreadViewOption { + return treeViewEnabled ? 'tree' : 'linear' +} diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts new file mode 100644 index 0000000000..9b74361307 --- /dev/null +++ b/src/state/queries/usePostThread/const.ts @@ -0,0 +1,27 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const LINEAR_VIEW_BELOW = 10 + +/** + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const LINEAR_VIEW_BF = 1 + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BELOW = 4 + +/** + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BF = undefined + +/** + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} + */ +export const TREE_VIEW_BELOW_DESKTOP = 6 diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts new file mode 100644 index 0000000000..782888cfbe --- /dev/null +++ b/src/state/queries/usePostThread/index.ts @@ -0,0 +1,325 @@ +import {useCallback, useMemo, useState} from 'react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' +import { + LINEAR_VIEW_BELOW, + LINEAR_VIEW_BF, + TREE_VIEW_BELOW, + TREE_VIEW_BELOW_DESKTOP, + TREE_VIEW_BF, +} from '#/state/queries/usePostThread/const' +import { + createCacheMutator, + getThreadPlaceholder, +} from '#/state/queries/usePostThread/queryCache' +import { + buildThread, + sortAndAnnotateThreadItems, +} from '#/state/queries/usePostThread/traversal' +import { + createPostThreadOtherQueryKey, + createPostThreadQueryKey, + type ThreadItem, + type UsePostThreadQueryResult, +} from '#/state/queries/usePostThread/types' +import {getThreadgateRecord} from '#/state/queries/usePostThread/utils' +import * as views from '#/state/queries/usePostThread/views' +import {useAgent, useSession} from '#/state/session' +import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {useBreakpoints} from '#/alf' + +export * from '#/state/queries/usePostThread/types' + +export function usePostThread({anchor}: {anchor?: string}) { + const qc = useQueryClient() + const agent = useAgent() + const {hasSession} = useSession() + const {gtPhone} = useBreakpoints() + const moderationOpts = useModerationOpts() + const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() + const { + isLoaded: isThreadPreferencesLoaded, + sort, + setSort: baseSetSort, + view, + setView: baseSetView, + prioritizeFollowedUsers, + } = useThreadPreferences() + const below = useMemo(() => { + return view === 'linear' + ? LINEAR_VIEW_BELOW + : isWeb && gtPhone + ? TREE_VIEW_BELOW_DESKTOP + : TREE_VIEW_BELOW + }, [view, gtPhone]) + + const postThreadQueryKey = createPostThreadQueryKey({ + anchor, + sort, + view, + prioritizeFollowedUsers, + }) + const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ + anchor, + prioritizeFollowedUsers, + }) + + const query = useQuery({ + enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, + queryKey: postThreadQueryKey, + async queryFn(ctx) { + const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ + anchor: anchor!, + branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, + below, + sort: sort, + prioritizeFollowedUsers: prioritizeFollowedUsers, + }) + + /* + * Initialize `ctx.meta` to track if we know we have additional replies + * we could fetch once we hit the end. + */ + ctx.meta = ctx.meta || { + hasOtherReplies: false, + } + + /* + * If we know we have additional replies, we'll set this to true. + */ + if (data.hasOtherReplies) { + ctx.meta.hasOtherReplies = true + } + + const result = { + thread: data.thread || [], + threadgate: data.threadgate, + hasOtherReplies: !!ctx.meta.hasOtherReplies, + } + + const record = getThreadgateRecord(result.threadgate) + if (result.threadgate && record) { + result.threadgate.record = record + } + + return result as UsePostThreadQueryResult + }, + placeholderData() { + if (!anchor) return + const placeholder = getThreadPlaceholder(qc, anchor) + /* + * Always return something here, even empty data, so that + * `isPlaceholderData` is always true, which we'll use to insert + * skeletons. + */ + const thread = placeholder ? [placeholder] : [] + return {thread, threadgate: undefined, hasOtherReplies: false} + }, + select(data) { + const record = getThreadgateRecord(data.threadgate) + if (data.threadgate && record) { + data.threadgate.record = record + } + return data + }, + }) + + const thread = useMemo(() => query.data?.thread || [], [query.data?.thread]) + const threadgate = useMemo( + () => query.data?.threadgate, + [query.data?.threadgate], + ) + const hasOtherThreadItems = useMemo( + () => !!query.data?.hasOtherReplies, + [query.data?.hasOtherReplies], + ) + const [otherItemsVisible, setOtherItemsVisible] = useState(false) + + /** + * Creates a mutator for the post thread cache. This is used to insert + * replies into the thread cache after posting. + */ + const mutator = useMemo( + () => + createCacheMutator({ + params: {view, below}, + postThreadQueryKey, + postThreadOtherQueryKey, + queryClient: qc, + }), + [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey], + ) + + /** + * If we have additional items available from the server and the user has + * chosen to view them, start loading data + */ + const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible + const additionalItemsQuery = useQuery({ + enabled: additionalQueryEnabled, + queryKey: postThreadOtherQueryKey, + async queryFn() { + const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ + anchor: anchor!, + prioritizeFollowedUsers, + }) + return data + }, + }) + const serverOtherThreadItems: ThreadItem[] = useMemo(() => { + if (!additionalQueryEnabled) return [] + if (additionalItemsQuery.isLoading) { + return Array.from({length: 2}).map((_, i) => + views.skeleton({ + key: `other-reply-${i}`, + item: 'reply', + }), + ) + } else if (additionalItemsQuery.isError) { + /* + * We could insert an special error component in here, but since these + * are optional additional replies, it's not critical that they're shown + * atm. + */ + return [] + } else if (additionalItemsQuery.data?.thread) { + const {threadItems} = sortAndAnnotateThreadItems( + additionalItemsQuery.data.thread, + { + view, + skipModerationHandling: true, + threadgateHiddenReplies: mergeThreadgateHiddenReplies( + threadgate?.record, + ), + moderationOpts: moderationOpts!, + }, + ) + return threadItems + } else { + return [] + } + }, [ + view, + additionalQueryEnabled, + additionalItemsQuery, + mergeThreadgateHiddenReplies, + moderationOpts, + threadgate?.record, + ]) + + /** + * Sets the sort order for the thread and resets the additional thread items + */ + const setSort: typeof baseSetSort = useCallback( + nextSort => { + setOtherItemsVisible(false) + baseSetSort(nextSort) + }, + [baseSetSort, setOtherItemsVisible], + ) + + /** + * Sets the view variant for the thread and resets the additional thread items + */ + const setView: typeof baseSetView = useCallback( + nextView => { + setOtherItemsVisible(false) + baseSetView(nextView) + }, + [baseSetView, setOtherItemsVisible], + ) + + /* + * This is the main thread response, sorted into separate buckets based on + * moderation, and annotated with all UI state needed for rendering. + */ + const {threadItems, otherThreadItems} = useMemo(() => { + return sortAndAnnotateThreadItems(thread, { + view: view, + threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), + moderationOpts: moderationOpts!, + }) + }, [ + thread, + threadgate?.record, + mergeThreadgateHiddenReplies, + moderationOpts, + view, + ]) + + /* + * Take all three sets of thread items and combine them into a single thread, + * along with any other thread items required for rendering e.g. "Show more + * replies" or the reply composer. + */ + const items = useMemo(() => { + return buildThread({ + threadItems, + otherThreadItems, + serverOtherThreadItems, + isLoading: query.isPlaceholderData, + hasSession, + hasOtherThreadItems, + otherItemsVisible, + showOtherItems: () => setOtherItemsVisible(true), + }) + }, [ + threadItems, + otherThreadItems, + serverOtherThreadItems, + query.isPlaceholderData, + hasSession, + hasOtherThreadItems, + otherItemsVisible, + setOtherItemsVisible, + ]) + + return useMemo( + () => ({ + state: { + /* + * Copy in any query state that is useful + */ + isFetching: query.isFetching, + isPlaceholderData: query.isPlaceholderData, + error: query.error, + /* + * Other state + */ + sort, + view, + otherItemsVisible, + }, + data: { + items, + threadgate, + }, + actions: { + /* + * Copy in any query actions that are useful + */ + insertReplies: mutator.insertReplies, + refetch: query.refetch, + /* + * Other actions + */ + setSort, + setView, + }, + }), + [ + query, + mutator.insertReplies, + otherItemsVisible, + sort, + view, + setSort, + setView, + threadgate, + items, + ], + ) +} diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts new file mode 100644 index 0000000000..871033395f --- /dev/null +++ b/src/state/queries/usePostThread/queryCache.ts @@ -0,0 +1,300 @@ +import { + type $Typed, + type AppBskyActorDefs, + type AppBskyFeedDefs, + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadOtherV2, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, +} from '@atproto/api' +import {type QueryClient} from '@tanstack/react-query' + +import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' +import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' +import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' +import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' +import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' +import {getBranch} from '#/state/queries/usePostThread/traversal' +import { + type ApiThreadItem, + type createPostThreadOtherQueryKey, + type createPostThreadQueryKey, + type PostThreadParams, + postThreadQueryKeyRoot, +} from '#/state/queries/usePostThread/types' +import {getRootPostAtUri} from '#/state/queries/usePostThread/utils' +import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views' +import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util' +import {embedViewRecordToPostView} from '#/state/queries/util' + +export function createCacheMutator({ + queryClient, + postThreadQueryKey, + postThreadOtherQueryKey, + params, +}: { + queryClient: QueryClient + postThreadQueryKey: ReturnType + postThreadOtherQueryKey: ReturnType + params: Pick & {below: number} +}) { + return { + insertReplies( + parentUri: string, + replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], + ) { + /* + * Main thread query mutator. + */ + queryClient.setQueryData( + postThreadQueryKey, + data => { + if (!data) return + return { + ...data, + thread: mutator([ + ...data.thread, + ]), + } + }, + ) + + /* + * Additional replies query mutator. + */ + queryClient.setQueryData( + postThreadOtherQueryKey, + data => { + if (!data) return + return { + ...data, + thread: mutator([ + ...data.thread, + ]), + } + }, + ) + + function mutator(thread: ApiThreadItem[]): T[] { + for (let i = 0; i < thread.length; i++) { + const existingParent = thread[i] + if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) + continue + if (existingParent.uri !== parentUri) continue + + /* + * Update parent data + */ + existingParent.value.post = { + ...existingParent.value.post, + replyCount: (existingParent.value.post.replyCount || 0) + 1, + } + + const opDid = getRootPostAtUri(existingParent.value.post)?.host + const nextItem = thread.at(i + 1) + const isReplyToRoot = existingParent.depth === 0 + const isEndOfReplyChain = + !nextItem || nextItem.depth <= existingParent.depth + const firstReply = replies.at(0) + const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( + firstReply?.value, + ) + ? opDid === firstReply.value.post.author.did + : false + + /* + * Always insert replies if the following conditions are met. + */ + const shouldAlwaysInsertReplies = + isReplyToRoot || + params.view === 'tree' || + (params.view === 'linear' && isEndOfReplyChain) + /* + * Maybe insert replies if the replier is the OP and certain conditions are met + */ + const shouldReplaceWithOPReplies = + !isReplyToRoot && params.view === 'linear' && opIsReplier + + if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) { + const branch = getBranch(thread, i, existingParent.depth) + /* + * OP insertions replace other replies _in linear view_. + */ + const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0 + const itemsToInsert = replies + .map((r, ri) => { + r.depth = existingParent.depth + 1 + ri + return r + }) + .filter(r => { + // Filter out replies that are too deep for our UI + return r.depth <= params.below + }) + + thread.splice(i + 1, itemsToRemove, ...itemsToInsert) + } + } + + return thread as T[] + } + }, + /** + * Unused atm, post shadow does the trick, but it would be nice to clean up + * the whole sub-tree on deletes. + */ + deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { + queryClient.setQueryData( + postThreadQueryKey, + queryData => { + if (!queryData) return + + const thread = [...queryData.thread] + + for (let i = 0; i < thread.length; i++) { + const existingPost = thread[i] + if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue + + if (existingPost.uri === post.uri) { + const branch = getBranch(thread, i, existingPost.depth) + thread.splice(branch.start, branch.length) + break + } + } + + return { + ...queryData, + thread, + } + }, + ) + }, + } +} + +export function getThreadPlaceholder( + queryClient: QueryClient, + uri: string, +): $Typed | void { + let partial + for (let item of getThreadPlaceholderCandidates(queryClient, uri)) { + /* + * Currently, the backend doesn't send full post info in some cases (for + * example, for quoted posts). We use missing `likeCount` as a way to + * detect that. In the future, we should fix this on the backend, which + * will let us always stop on the first result. + * + * TODO can we send in feeds and quotes? + */ + const hasAllInfo = item.value.post.likeCount != null + if (hasAllInfo) { + return item + } else { + // Keep searching, we might still find a full post in the cache. + partial = item + } + } + return partial +} + +export function* getThreadPlaceholderCandidates( + queryClient: QueryClient, + uri: string, +): Generator< + $Typed< + Omit & { + value: $Typed + } + >, + void +> { + /* + * Check post thread queries first + */ + for (const post of findAllPostsInQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + + /* + * Check notifications first. If you have a post in notifications, it's + * often due to a like or a repost, and we want to prioritize a post object + * with >0 likes/reposts over a stale version with no metrics in order to + * avoid a notification->post scroll jump. + */ + for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { + yield postViewToThreadPlaceholder(post) + } + for (let post of findAllPostsInExploreFeedPreviewsQueryData( + queryClient, + uri, + )) { + yield postViewToThreadPlaceholder(post) + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator { + const atUri = new AtUri(uri) + const queryDatas = + queryClient.getQueriesData({ + queryKey: [postThreadQueryKeyRoot], + }) + + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) continue + + const {thread} = queryData + + for (const item of thread) { + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + if (didOrHandleUriMatches(atUri, item.value.post)) { + yield item.value.post + } + + const qp = getEmbeddedPost(item.value.post.embed) + if (qp && didOrHandleUriMatches(atUri, qp)) { + yield embedViewRecordToPostView(qp) + } + } + } + } +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator { + const queryDatas = + queryClient.getQueriesData({ + queryKey: [postThreadQueryKeyRoot], + }) + + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) continue + + const {thread} = queryData + + for (const item of thread) { + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + if (item.value.post.author.did === did) { + yield item.value.post.author + } + + const qp = getEmbeddedPost(item.value.post.embed) + if (qp && qp.author.did === did) { + yield qp.author + } + } + } + } +} diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts new file mode 100644 index 0000000000..124591125f --- /dev/null +++ b/src/state/queries/usePostThread/traversal.ts @@ -0,0 +1,563 @@ +/* eslint-disable no-labels */ +import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api' + +import { + type ApiThreadItem, + type PostThreadParams, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' +import { + getPostRecord, + getThreadPostNoUnauthenticatedUI, + getThreadPostUI, + getTraversalMetadata, + storeTraversalMetadata, +} from '#/state/queries/usePostThread/utils' +import * as views from '#/state/queries/usePostThread/views' + +export function sortAndAnnotateThreadItems( + thread: ApiThreadItem[], + { + threadgateHiddenReplies, + moderationOpts, + view, + skipModerationHandling, + }: { + threadgateHiddenReplies: Set + moderationOpts: ModerationOpts + view: PostThreadParams['view'] + /** + * Set to `true` in cases where we already know the moderation state of the + * post e.g. when fetching additional replies from the server. This will + * prevent additional sorting or nested-branch truncation, and all replies, + * regardless of moderation state, will be included in the resulting + * `threadItems` array. + */ + skipModerationHandling?: boolean + }, +) { + const threadItems: ThreadItem[] = [] + const otherThreadItems: ThreadItem[] = [] + const metadatas = new Map() + + traversal: for (let i = 0; i < thread.length; i++) { + const item = thread[i] + let parentMetadata: TraversalMetadata | undefined + let metadata: TraversalMetadata | undefined + + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + parentMetadata = metadatas.get( + getPostRecord(item.value.post).reply?.parent?.uri || '', + ) + metadata = getTraversalMetadata({ + item, + parentMetadata, + prevItem: thread.at(i - 1), + nextItem: thread.at(i + 1), + }) + storeTraversalMetadata(metadatas, metadata) + } + + if (item.depth < 0) { + /* + * Parents are ignored until we find the anchor post, then we walk + * _up_ from there. + */ + } else if (item.depth === 0) { + if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) { + threadItems.push(views.threadPostNoUnauthenticated(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) { + threadItems.push(views.threadPostNotFound(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) { + threadItems.push(views.threadPostBlocked(item)) + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + const post = views.threadPost({ + uri: item.uri, + depth: item.depth, + value: item.value, + moderationOpts, + threadgateHiddenReplies, + }) + threadItems.push(post) + + parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { + const parent = thread[pi] + + if ( + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value) + ) { + const post = views.threadPostNoUnauthenticated(parent) + post.ui = getThreadPostNoUnauthenticatedUI({ + depth: parent.depth, + // ignore for now + // prevItemDepth: thread[pi - 1]?.depth, + nextItemDepth: thread[pi + 1]?.depth, + }) + threadItems.unshift(post) + // for now, break parent traversal at first no-unauthed + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) { + threadItems.unshift(views.threadPostNotFound(parent)) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) { + threadItems.unshift(views.threadPostBlocked(parent)) + break parentTraversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) { + threadItems.unshift( + views.threadPost({ + uri: parent.uri, + depth: parent.depth, + value: parent.value, + moderationOpts, + threadgateHiddenReplies, + }), + ) + } + } + } + } else if (item.depth > 0) { + /* + * The API does not send down any unavailable replies, so this will + * always be false (for now). If we ever wanted to tombstone them here, + * we could. + */ + const shouldBreak = + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) || + AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) || + AppBskyUnspeccedDefs.isThreadItemBlocked(item.value) + + if (shouldBreak) { + const branch = getBranch(thread, i, item.depth) + // could insert tombstone + i = branch.end + continue traversal + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + if (parentMetadata) { + /* + * Set this value before incrementing the parent's repliesSeenCounter + */ + metadata!.replyIndex = parentMetadata.repliesIndexCounter + // Increment the parent's repliesIndexCounter + parentMetadata.repliesIndexCounter += 1 + } + + const post = views.threadPost({ + uri: item.uri, + depth: item.depth, + value: item.value, + moderationOpts, + threadgateHiddenReplies, + }) + + if (!post.isBlurred || skipModerationHandling) { + /* + * Not moderated, need to insert it + */ + threadItems.push(post) + + /* + * Update seen reply count of parent + */ + if (parentMetadata) { + parentMetadata.repliesSeenCounter += 1 + } + } else { + /* + * Moderated in some way, we're going to walk children + */ + const parent = post + const parentIsTopLevelReply = parent.depth === 1 + // get sub tree + const branch = getBranch(thread, i, item.depth) + + if (parentIsTopLevelReply) { + // push branch anchor into sorted array + otherThreadItems.push(parent) + // skip branch anchor in branch traversal + const startIndex = branch.start + 1 + + for (let ci = startIndex; ci <= branch.end; ci++) { + const child = thread[ci] + + if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) { + const childParentMetadata = metadatas.get( + getPostRecord(child.value.post).reply?.parent?.uri || '', + ) + const childMetadata = getTraversalMetadata({ + item: child, + prevItem: thread[ci - 1], + nextItem: thread[ci + 1], + parentMetadata: childParentMetadata, + }) + storeTraversalMetadata(metadatas, childMetadata) + if (childParentMetadata) { + /* + * Set this value before incrementing the parent's repliesIndexCounter + */ + childMetadata!.replyIndex = + childParentMetadata.repliesIndexCounter + childParentMetadata.repliesIndexCounter += 1 + } + + const childPost = views.threadPost({ + uri: child.uri, + depth: child.depth, + value: child.value, + moderationOpts, + threadgateHiddenReplies, + }) + + /* + * If a child is moderated in any way, drop it an its sub-branch + * entirely. To reveal these, the user must navigate to the + * parent post directly. + */ + if (childPost.isBlurred) { + ci = getBranch(thread, ci, child.depth).end + } else { + otherThreadItems.push(childPost) + + if (childParentMetadata) { + childParentMetadata.repliesSeenCounter += 1 + } + } + } else { + /* + * Drop the rest of the branch if we hit anything unexpected + */ + break + } + } + } + + /* + * Skip to next branch + */ + i = branch.end + continue traversal + } + } + } + } + + /* + * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute + * UI state based on collected metadata. These arrays will be muted in situ. + */ + for (const subset of [threadItems, otherThreadItems]) { + for (let i = 0; i < subset.length; i++) { + const item = subset[i] + const prevItem = subset.at(i - 1) + const nextItem = subset.at(i + 1) + + if (item.type === 'threadPost') { + const metadata = metadatas.get(item.uri) + + if (metadata) { + if (metadata.parentMetadata) { + /* + * Track what's before/after now that we've applied moderation + */ + if (prevItem?.type === 'threadPost') + metadata.prevItemDepth = prevItem?.depth + if (nextItem?.type === 'threadPost') + metadata.nextItemDepth = nextItem?.depth + + /* + * Item is the last "sibling" if we know for sure we're out of + * replies on the parent (even though this item itself may have its + * own reply branches). + */ + const isLastSiblingByCounts = + metadata.replyIndex === + metadata.parentMetadata.repliesIndexCounter - 1 + + /* + * Item can also be the last "sibling" if we know we don't have a + * next item, OR if that next item's depth is less than this item's + * depth (meaning it's a sibling of the parent, not a child of this + * item). + */ + const isImplicitlyLastSibling = + metadata.nextItemDepth === undefined || + metadata.nextItemDepth < metadata.depth + + /* + * Ok now we can set the last sibling state. + */ + metadata.isLastSibling = + isLastSiblingByCounts || isImplicitlyLastSibling + + /* + * Item is the last "child" in a branch if there is no next item, + * or if the next item's depth is less than this item's depth (a + * sibling of the parent) or equal to this item's depth (a sibling + * of this item) + */ + metadata.isLastChild = + metadata.nextItemDepth === undefined || + metadata.nextItemDepth <= metadata.depth + + /* + * If this is the last sibling, it's implicitly part of the last + * branch of this sub-tree. + */ + if (metadata.isLastSibling) { + metadata.isPartOfLastBranchFromDepth = metadata.depth + + /** + * If the parent is part of the last branch of the sub-tree, so is the child. + */ + if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { + metadata.isPartOfLastBranchFromDepth = + metadata.parentMetadata.isPartOfLastBranchFromDepth + } + } + + /* + * If this is the last sibling, and the parent has unhydrated replies, + * at some point down the line we will need to show a "read more". + */ + if ( + metadata.parentMetadata.repliesUnhydrated > 0 && + metadata.isLastSibling + ) { + metadata.upcomingParentReadMore = metadata.parentMetadata + } + + /* + * Copy in the parent's upcoming read more, if it exists. Once we + * reach the bottom, we'll insert a "read more" + */ + if (metadata.parentMetadata.upcomingParentReadMore) { + metadata.upcomingParentReadMore = + metadata.parentMetadata.upcomingParentReadMore + } + + /* + * Copy in the parent's skipped indents + */ + metadata.skippedIndentIndices = new Set([ + ...metadata.parentMetadata.skippedIndentIndices, + ]) + + /** + * If this is the last sibling, and the parent has no unhydrated + * replies, then we know we can skip an indent line. + */ + if ( + metadata.parentMetadata.repliesUnhydrated <= 0 && + metadata.isLastSibling + ) { + /** + * Depth is 2 more than the 0-index of the indent calculation + * bc of how we render these. So instead of handling that in the + * component, we just adjust that back to 0-index here. + */ + metadata.skippedIndentIndices.add(item.depth - 2) + } + } + + /* + * If this post has unhydrated replies, and it is the last child, then + * it itself needs a "read more" + */ + if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) { + metadata.precedesChildReadMore = true + subset.splice(i + 1, 0, views.readMore(metadata)) + i++ // skip next iteration + } + + /* + * Tree-view only. + * + * If there's an upcoming parent read more, this branch is part of the + * last branch of the sub-tree, and the item itself is the last child, + * insert the parent "read more". + */ + if ( + view === 'tree' && + metadata.upcomingParentReadMore && + metadata.isPartOfLastBranchFromDepth === + metadata.upcomingParentReadMore.depth && + metadata.isLastChild + ) { + subset.splice( + i + 1, + 0, + views.readMore(metadata.upcomingParentReadMore), + ) + i++ + } + + /** + * Only occurs for the first item in the thread, which may have + * additional parents not included in this request. + */ + if (item.value.moreParents) { + metadata.followsReadMoreUp = true + subset.splice(i, 0, views.readMoreUp(metadata)) + i++ + } + + /* + * Calculate the final UI state for the thread item. + */ + item.ui = getThreadPostUI(metadata) + } + } + } + } + + return { + threadItems, + otherThreadItems, + } +} + +export function buildThread({ + threadItems, + otherThreadItems, + serverOtherThreadItems, + isLoading, + hasSession, + otherItemsVisible, + hasOtherThreadItems, + showOtherItems, +}: { + threadItems: ThreadItem[] + otherThreadItems: ThreadItem[] + serverOtherThreadItems: ThreadItem[] + isLoading: boolean + hasSession: boolean + otherItemsVisible: boolean + hasOtherThreadItems: boolean + showOtherItems: () => void +}) { + /** + * `threadItems` is memoized here, so don't mutate it directly. + */ + const items = [...threadItems] + + if (isLoading) { + const anchorPost = items.at(0) + const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost' + const skeletonReplies = hasAnchorFromCache + ? anchorPost.value.post.replyCount ?? 4 + : 4 + + if (!items.length) { + items.push( + views.skeleton({ + key: 'anchor-skeleton', + item: 'anchor', + }), + ) + } + + if (hasSession) { + // we might have this from cache + const replyDisabled = + hasAnchorFromCache && + anchorPost.value.post.viewer?.replyDisabled === true + + if (hasAnchorFromCache) { + if (!replyDisabled) { + items.push({ + type: 'replyComposer', + key: 'replyComposer', + }) + } + } else { + items.push( + views.skeleton({ + key: 'replyComposer', + item: 'replyComposer', + }), + ) + } + } + + for (let i = 0; i < skeletonReplies; i++) { + items.push( + views.skeleton({ + key: `anchor-skeleton-reply-${i}`, + item: 'reply', + }), + ) + } + } else { + for (let i = 0; i < items.length; i++) { + const item = items[i] + if ( + item.type === 'threadPost' && + item.depth === 0 && + !item.value.post.viewer?.replyDisabled && + hasSession + ) { + items.splice(i + 1, 0, { + type: 'replyComposer', + key: 'replyComposer', + }) + break + } + } + + if (otherThreadItems.length || hasOtherThreadItems) { + if (otherItemsVisible) { + items.push(...otherThreadItems) + items.push(...serverOtherThreadItems) + } else { + items.push({ + type: 'showOtherReplies', + key: 'showOtherReplies', + onPress: showOtherItems, + }) + } + } + } + + return items +} + +/** + * Get the start and end index of a "branch" of the thread. A "branch" is a + * parent and it's children (not siblings). Returned indices are inclusive of + * the parent and its last child. + * + * items[] (index, depth) + * └─┬ anchor ──────── (0, 0) + * ├─── branch ───── (1, 1) + * ├──┬ branch ───── (2, 1) (start) + * │ ├──┬ leaf ──── (3, 2) + * │ │ └── leaf ── (4, 3) + * │ └─── leaf ──── (5, 2) (end) + * ├─── branch ───── (6, 1) + * └─── branch ───── (7, 1) + * + * const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1) + */ +export function getBranch( + thread: ApiThreadItem[], + branchStartIndex: number, + branchStartDepth: number, +) { + let end = branchStartIndex + + for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { + const next = thread[ci] + if (next.depth > branchStartDepth) { + end = ci + } else { + end = ci - 1 + break + } + } + + return { + start: branchStartIndex, + end, + length: end - branchStartIndex, + } +} diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts new file mode 100644 index 0000000000..2f370b0ab7 --- /dev/null +++ b/src/state/queries/usePostThread/types.ts @@ -0,0 +1,227 @@ +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadOtherV2, + type AppBskyUnspeccedGetPostThreadV2, + type ModerationDecision, +} from '@atproto/api' + +export type ApiThreadItem = + | AppBskyUnspeccedGetPostThreadV2.ThreadItem + | AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem + +export const postThreadQueryKeyRoot = 'post-thread-v2' as const + +export const createPostThreadQueryKey = (props: PostThreadParams) => + [postThreadQueryKeyRoot, props] as const + +export const createPostThreadOtherQueryKey = ( + props: Omit & { + anchor?: string + }, +) => [postThreadQueryKeyRoot, 'other', props] as const + +export type PostThreadParams = Pick< + AppBskyUnspeccedGetPostThreadV2.QueryParams, + 'sort' | 'prioritizeFollowedUsers' +> & { + anchor?: string + view: 'tree' | 'linear' +} + +export type UsePostThreadQueryResult = { + hasOtherReplies: boolean + thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] + threadgate?: Omit & { + record: AppBskyFeedThreadgate.Record + } +} + +export type ThreadItem = + | { + type: 'threadPost' + key: string + uri: string + depth: number + value: Omit & { + post: Omit & { + record: AppBskyFeedPost.Record + } + } + isBlurred: boolean + moderation: ModerationDecision + ui: { + isAnchor: boolean + showParentReplyLine: boolean + showChildReplyLine: boolean + indent: number + isLastChild: boolean + skippedIndentIndices: Set + precedesChildReadMore: boolean + } + } + | { + type: 'threadPostNoUnauthenticated' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated + ui: { + showParentReplyLine: boolean + showChildReplyLine: boolean + } + } + | { + type: 'threadPostNotFound' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemNotFound + } + | { + type: 'threadPostBlocked' + key: string + uri: string + depth: number + value: AppBskyUnspeccedDefs.ThreadItemBlocked + } + | { + type: 'replyComposer' + key: string + } + | { + type: 'showOtherReplies' + key: string + onPress: () => void + } + | { + /* + * Read more replies, downwards in the thread. + */ + type: 'readMore' + key: string + depth: number + href: string + moreReplies: number + skippedIndentIndices: Set + } + | { + /* + * Read more parents, upwards in the thread. + */ + type: 'readMoreUp' + key: string + href: string + } + | { + type: 'skeleton' + key: string + item: 'anchor' | 'reply' | 'replyComposer' + } + +/** + * Metadata collected while traversing the raw data from the thread response. + * Some values here can be computed immediately, while others need to be + * computed during a second pass over the thread after we know things like + * total number of replies, the reply index, etc. + * + * The idea here is that these values should be objectively true in all cases, + * such that we can use them later — either individually on in composite — to + * drive rendering behaviors. + */ +export type TraversalMetadata = { + /** + * The depth of the post in the reply tree, where 0 is the root post. This is + * calculated on the server. + */ + depth: number + /** + * Indicates if this item is a "read more" link preceding this post that + * continues the thread upwards. + */ + followsReadMoreUp: boolean + /** + * Indicates if the post is the last reply beneath its parent post. + */ + isLastSibling: boolean + /** + * Indicates the post is the end-of-the-line for a given branch of replies. + */ + isLastChild: boolean + /** + * Indicates if the post is the left/lower-most branch of the reply tree. + * Value corresponds to the depth at which this branch started. + */ + isPartOfLastBranchFromDepth?: number + /** + * The depth of the slice immediately following this one, if it exists. + */ + nextItemDepth?: number + /** + * This is a live reference to the parent metadata object. Mutations to this + * are available for later use in children. + */ + parentMetadata?: TraversalMetadata + /** + * Populated during the final traversal of the thread. Denotes whether + * there is a "Read more" link for this item immediately following + * this item. + */ + precedesChildReadMore: boolean + /** + * The depth of the slice immediately preceding this one, if it exists. + */ + prevItemDepth?: number + /** + * Any data needed to be passed along to the "read more" items. Keep this + * trim for better memory usage. + */ + postData: { + uri: string + authorHandle: string + } + /** + * The total number of replies to this post, including those not hydrated + * and returned by the response. + */ + repliesCount: number + /** + * The number of replies to this post not hydrated and returned by the + * response. + */ + repliesUnhydrated: number + /** + * The number of replies that have been seen so far in the traversal. + * Excludes replies that are moderated in some way, since those are not + * "seen" on first load. Use `repliesIndexCounter` for the total number of + * replies that were hydrated in the response. + * + * After traversal, we can use this to calculate if we actually got all the + * replies we expected, or if some were blocked, etc. + */ + repliesSeenCounter: number + /** + * The total number of replies to this post hydrated in this response. Used + * for populating the `replyIndex` of the post by referencing this value on + * the parent. + */ + repliesIndexCounter: number + /** + * The index-0-based index of this reply in the parent post's replies. + */ + replyIndex: number + /** + * Each slice is responsible for rendering reply lines based on its depth. + * This value corresponds to any line indices that can be skipped e.g. + * because there are no further replies below this sub-tree to render. + */ + skippedIndentIndices: Set + /** + * Indicates and stores parent data IF that parent has additional unhydrated + * replies. This value is passed down to children along the left/lower-most + * branch of the tree. When the end is reached, a "read more" is inserted. + */ + upcomingParentReadMore?: TraversalMetadata +} diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts new file mode 100644 index 0000000000..b8ab340d87 --- /dev/null +++ b/src/state/queries/usePostThread/utils.ts @@ -0,0 +1,170 @@ +import { + type AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, +} from '@atproto/api' + +import { + type ApiThreadItem, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' +import {isDevMode} from '#/storage/hooks/dev-mode' +import * as bsky from '#/types/bsky' + +export function getThreadgateRecord( + view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'], +) { + return bsky.dangerousIsType( + view?.record, + AppBskyFeedThreadgate.isRecord, + ) + ? view?.record + : undefined +} + +export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) { + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + if (post.record.reply?.root?.uri) { + return new AtUri(post.record.reply.root.uri) + } + } +} + +export function getPostRecord(post: AppBskyFeedDefs.PostView) { + return post.record as AppBskyFeedPost.Record +} + +export function getTraversalMetadata({ + item, + prevItem, + nextItem, + parentMetadata, +}: { + item: ApiThreadItem + prevItem?: ApiThreadItem + nextItem?: ApiThreadItem + parentMetadata?: TraversalMetadata +}): TraversalMetadata { + if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { + throw new Error(`Expected thread item to be a post`) + } + const repliesCount = item.value.post.replyCount || 0 + const repliesUnhydrated = item.value.moreReplies || 0 + const metadata = { + depth: item.depth, + /* + * Unknown until after traversal + */ + isLastChild: false, + /* + * Unknown until after traversal + */ + isLastSibling: false, + /* + * If it's a top level reply, bc we render each top-level branch as a + * separate tree, it's implicitly part of the last branch. For subsequent + * replies, we'll override this after traversal. + */ + isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined, + nextItemDepth: nextItem?.depth, + parentMetadata, + prevItemDepth: prevItem?.depth, + /* + * Unknown until after traversal + */ + precedesChildReadMore: false, + /* + * Unknown until after traversal + */ + followsReadMoreUp: false, + postData: { + uri: item.uri, + authorHandle: item.value.post.author.handle, + }, + repliesCount, + repliesUnhydrated, + repliesSeenCounter: 0, + repliesIndexCounter: 0, + replyIndex: 0, + skippedIndentIndices: new Set(), + } + + if (isDevMode()) { + // @ts-ignore dev only for debugging + metadata.postData.text = getPostRecord(item.value.post).text + } + + return metadata +} + +export function storeTraversalMetadata( + metadatas: Map, + metadata: TraversalMetadata, +) { + metadatas.set(metadata.postData.uri, metadata) + + if (isDevMode()) { + // @ts-ignore dev only for debugging + metadatas.set(metadata.postData.text, metadata) + // @ts-ignore + window.__thread = metadatas + } +} + +export function getThreadPostUI({ + depth, + repliesCount, + prevItemDepth, + isLastChild, + skippedIndentIndices, + repliesSeenCounter, + repliesUnhydrated, + precedesChildReadMore, + followsReadMoreUp, +}: TraversalMetadata): Extract['ui'] { + const isReplyAndHasReplies = + depth > 0 && + repliesCount > 0 && + (repliesCount - repliesUnhydrated === repliesSeenCounter || + repliesSeenCounter > 0) + return { + isAnchor: depth === 0, + showParentReplyLine: + followsReadMoreUp || + (!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth), + showChildReplyLine: depth < 0 || isReplyAndHasReplies, + indent: depth, + /* + * If there are no slices below this one, or the next slice has a depth <= + * than the depth of this post, it's the last child of the reply tree. It + * is not necessarily the last leaf in the parent branch, since it could + * have another sibling. + */ + isLastChild, + skippedIndentIndices, + precedesChildReadMore: precedesChildReadMore ?? false, + } +} + +export function getThreadPostNoUnauthenticatedUI({ + depth, + prevItemDepth, +}: { + depth: number + prevItemDepth?: number + nextItemDepth?: number +}): Extract['ui'] { + return { + showChildReplyLine: depth < 0, + showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth), + } +} diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts new file mode 100644 index 0000000000..71acfc77bc --- /dev/null +++ b/src/state/queries/usePostThread/views.ts @@ -0,0 +1,183 @@ +import { + type $Typed, + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadV2, + AtUri, + moderatePost, + type ModerationOpts, +} from '@atproto/api' + +import {makeProfileLink} from '#/lib/routes/links' +import { + type ApiThreadItem, + type ThreadItem, + type TraversalMetadata, +} from '#/state/queries/usePostThread/types' + +export function threadPostNoUnauthenticated({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostNoUnauthenticated', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated, + // @ts-ignore populated by the traversal + ui: {}, + } +} + +export function threadPostNotFound({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostNotFound', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemNotFound, + } +} + +export function threadPostBlocked({ + uri, + depth, + value, +}: ApiThreadItem): Extract { + return { + type: 'threadPostBlocked', + key: uri, + uri, + depth, + value: value as AppBskyUnspeccedDefs.ThreadItemBlocked, + } +} + +export function threadPost({ + uri, + depth, + value, + moderationOpts, + threadgateHiddenReplies, +}: { + uri: string + depth: number + value: $Typed + moderationOpts: ModerationOpts + threadgateHiddenReplies: Set +}): Extract { + const moderation = moderatePost(value.post, moderationOpts) + const modui = moderation.ui('contentList') + const blurred = modui.blur || modui.filter + const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' + const hiddenByThreadgate = threadgateHiddenReplies.has(uri) + const isBlurred = hiddenByThreadgate || blurred || muted + return { + type: 'threadPost', + key: uri, + uri, + depth, + value: { + ...value, + /* + * Do not spread anything here, load bearing for post shadow strict + * equality reference checks. + */ + post: value.post as Omit & { + record: AppBskyFeedPost.Record + }, + }, + isBlurred, + moderation, + // @ts-ignore populated by the traversal + ui: {}, + } +} + +export function readMore({ + depth, + repliesUnhydrated, + skippedIndentIndices, + postData, +}: TraversalMetadata): Extract { + const urip = new AtUri(postData.uri) + const href = makeProfileLink( + { + did: urip.host, + handle: postData.authorHandle, + }, + 'post', + urip.rkey, + ) + return { + type: 'readMore' as const, + key: `readMore:${postData.uri}`, + href, + moreReplies: repliesUnhydrated, + depth, + skippedIndentIndices, + } +} + +export function readMoreUp({ + postData, +}: TraversalMetadata): Extract { + const urip = new AtUri(postData.uri) + const href = makeProfileLink( + { + did: urip.host, + handle: postData.authorHandle, + }, + 'post', + urip.rkey, + ) + return { + type: 'readMoreUp' as const, + key: `readMoreUp:${postData.uri}`, + href, + } +} + +export function skeleton({ + key, + item, +}: Omit, 'type'>): Extract< + ThreadItem, + {type: 'skeleton'} +> { + return { + type: 'skeleton', + key, + item, + } +} + +export function postViewToThreadPlaceholder( + post: AppBskyFeedDefs.PostView, +): $Typed< + Omit & { + value: $Typed + } +> { + return { + $type: 'app.bsky.unspecced.getPostThreadV2#threadItem', + uri: post.uri, + depth: 0, // reset to 0 for highlighted post + value: { + $type: 'app.bsky.unspecced.defs#threadItemPost', + post, + opThread: false, + moreParents: false, + moreReplies: 0, + hiddenByThreadgate: false, + mutedByViewer: false, + }, + } +} diff --git a/src/state/session/types.ts b/src/state/session/types.ts index 9aadf9d051..aa8b9a99e0 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -1,5 +1,5 @@ -import {LogEvents} from '#/lib/statsig/statsig' -import {PersistedAccount} from '#/state/persisted' +import {type LogEvents} from '#/lib/statsig/statsig' +import {type PersistedAccount} from '#/state/persisted' export type SessionAccount = PersistedAccount diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index ad07333beb..b317942480 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import { type AppBskyActorDefs, type AppBskyFeedDefs, + type AppBskyUnspeccedGetPostThreadV2, type ModerationDecision, } from '@atproto/api' import {msg} from '@lingui/macro' @@ -24,9 +25,17 @@ export interface ComposerOptsPostRef { moderation?: ModerationDecision } +export type OnPostSuccessData = + | { + replyToUri?: string + posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] + } + | undefined + export interface ComposerOpts { replyTo?: ComposerOptsPostRef onPost?: (postUri: string | undefined) => void + onPostSuccess?: (data: OnPostSuccessData) => void quote?: AppBskyFeedDefs.PostView mention?: string // handle of user to mention openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx index 60806f5706..8a3ee0f24a 100644 --- a/src/state/threadgate-hidden-replies.tsx +++ b/src/state/threadgate-hidden-replies.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AppBskyFeedThreadgate} from '@atproto/api' +import {type AppBskyFeedThreadgate} from '@atproto/api' type StateContext = { uris: Set @@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({ return set }, [uris, recentlyUnhiddenUris, threadgateRecord]) } + +export function useMergeThreadgateHiddenReplies() { + const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris() + return React.useCallback( + (threadgate?: AppBskyFeedThreadgate.Record) => { + const set = new Set([...(threadgate?.hiddenReplies || []), ...uris]) + for (const uri of recentlyUnhiddenUris) { + set.delete(uri) + } + return set + }, + [uris, recentlyUnhiddenUris], + ) +} diff --git a/src/state/unstable-post-source.tsx b/src/state/unstable-post-source.tsx index 1fb4af2872..450f2c120b 100644 --- a/src/state/unstable-post-source.tsx +++ b/src/state/unstable-post-source.tsx @@ -1,73 +1,102 @@ -import {createContext, useCallback, useContext, useState} from 'react' -import {type AppBskyFeedDefs} from '@atproto/api' +import {useEffect, useId, useState} from 'react' +import {type AppBskyFeedDefs, AtUri} from '@atproto/api' -import {type FeedDescriptor} from './queries/post-feed' +import {Logger} from '#/logger' +import {type FeedDescriptor} from '#/state/queries/post-feed' /** - * For passing the source of the post (i.e. the original post, from the feed) to the threadview, - * without using query params. Deliberately unstable to avoid using query params, use for FeedFeedback - * and other ephemeral non-critical systems. + * Separate logger for better debugging */ +const logger = Logger.create(Logger.Context.PostSource) -type Source = { +export type PostSource = { post: AppBskyFeedDefs.FeedViewPost feed?: FeedDescriptor } -const SetUnstablePostSourceContext = createContext< - (key: string, source: Source) => void ->(() => {}) -const ConsumeUnstablePostSourceContext = createContext< - (uri: string) => Source | undefined ->(() => undefined) +/** + * A cache of sources that will be consumed by the post thread view. This is + * cleaned up any time a source is consumed. + */ +const transientSources = new Map() -export function Provider({children}: {children: React.ReactNode}) { - const [sources, setSources] = useState>(() => new Map()) +/** + * A cache of sources that have been consumed by the post thread view. This is + * not cleaned up, but because we use a new ID for each post thread view that + * consumes a source, this is never reused unless a user navigates back to a + * post thread view that has not been dropped from memory. + */ +const consumedSources = new Map() - const setUnstablePostSource = useCallback((key: string, source: Source) => { - setSources(prev => { - const newMap = new Map(prev) - newMap.set(key, source) - return newMap - }) - }, []) - - const consumeUnstablePostSource = useCallback( - (uri: string) => { - const source = sources.get(uri) - if (source) { - setSources(prev => { - const newMap = new Map(prev) - newMap.delete(uri) - return newMap - }) - } - return source - }, - [sources], +/** + * For stashing the feed that the user was browsing when they clicked on a post. + * + * Used for FeedFeedback and other ephemeral non-critical systems. + */ +export function setUnstablePostSource(key: string, source: PostSource) { + assertValidDevOnly( + key, + `setUnstablePostSource key should be a URI containing a handle, received ${key} — use buildPostSourceKey`, ) - - return ( - - - {children} - - - ) -} - -export function useSetUnstablePostSource() { - return useContext(SetUnstablePostSourceContext) + logger.debug('set', {key, source}) + transientSources.set(key, source) } /** - * DANGER - This hook is unstable and should only be used for FeedFeedback - * and other ephemeral non-critical systems. Does not change when the URI changes. + * This hook is unstable and should only be used for FeedFeedback and other + * ephemeral non-critical systems. Views that use this hook will continue to + * return a reference to the same source until those views are dropped from + * memory. */ -export function useUnstablePostSource(uri: string) { - const consume = useContext(ConsumeUnstablePostSourceContext) +export function useUnstablePostSource(key: string) { + const id = useId() + const [source] = useState(() => { + assertValidDevOnly( + key, + `consumeUnstablePostSource key should be a URI containing a handle, received ${key} — be sure to use buildPostSourceKey when setting the source`, + true, + ) + const source = consumedSources.get(id) || transientSources.get(key) + if (source) { + logger.debug('consume', {id, key, source}) + transientSources.delete(key) + consumedSources.set(id, source) + } + return source + }) + + useEffect(() => { + return () => { + consumedSources.delete(id) + logger.debug('cleanup', {id}) + } + }, [id]) - const [source] = useState(() => consume(uri)) return source } + +/** + * Builds a post source key. This (atm) is a URI where the `host` is the post + * author's handle, not DID. + */ +export function buildPostSourceKey(key: string, handle: string) { + const urip = new AtUri(key) + urip.host = handle + return urip.toString() +} + +/** + * Just a lil dev helper + */ +function assertValidDevOnly(key: string, message: string, beChill = false) { + if (__DEV__) { + const urip = new AtUri(key) + if (urip.host.startsWith('did:')) { + if (beChill) { + logger.warn(message) + } else { + throw new Error(message) + } + } + } +} diff --git a/src/storage/hooks/dev-mode.ts b/src/storage/hooks/dev-mode.ts index 49eca3bb11..331825c48f 100644 --- a/src/storage/hooks/dev-mode.ts +++ b/src/storage/hooks/dev-mode.ts @@ -5,3 +5,17 @@ export function useDevMode() { return [devMode, setDevMode] as const } + +let cachedIsDevMode: boolean | undefined +/** + * Does not update when toggling dev mode on or off. This util simply retrieves + * the value and caches in memory indefinitely. So after an update, you'll need + * to reload the app so it can pull a fresh value from storage. + */ +export function isDevMode() { + if (__DEV__) return true + if (cachedIsDevMode === undefined) { + cachedIsDevMode = device.get(['devMode']) ?? false + } + return cachedIsDevMode +} diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000000..f64922a1f1 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,5 @@ +export type Literal = T extends A + ? string extends T + ? never + : T + : never diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 42f057803f..de060c6c22 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, type AppBskyFeedGetPostThread, + AppBskyUnspeccedDefs, type BskyAgent, type RichText, } from '@atproto/api' @@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' +import {retry} from '#/lib/async/retry' import {until} from '#/lib/async/until' import { MAX_GRAPHEME_LENGTH, @@ -62,6 +64,7 @@ import { type SupportedMimeTypes, } from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useAppState} from '#/lib/hooks/useAppState' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' @@ -69,7 +72,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' -import {colors, s} from '#/lib/styles' +import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' @@ -87,13 +90,14 @@ import {useProfileQuery} from '#/state/queries/profile' import {type Gif} from '#/state/queries/tenor' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' -import {type ComposerOpts} from '#/state/shell/composer' +import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' import { ExternalEmbedGif, ExternalEmbedLink, } from '#/view/com/composer/ExternalEmbed' +import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {GifAltTextDialog} from '#/view/com/composer/GifAltText' import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' @@ -113,7 +117,6 @@ import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' -import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed' import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' @@ -122,6 +125,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' @@ -152,6 +156,7 @@ type Props = ComposerOpts export const ComposePost = ({ replyTo, onPost, + onPostSuccess, quote: initQuote, mention: initMention, openEmojiPicker, @@ -388,8 +393,10 @@ export const ComposePost = ({ setError('') setIsPublishing(true) - let postUri + let postUri: string | undefined + let postSuccessData: OnPostSuccessData try { + logger.info(`composer: posting...`) postUri = ( await apilib.post(agent, queryClient, { thread, @@ -398,16 +405,48 @@ export const ComposePost = ({ langs: toPostLanguages(langPrefs.postLanguage), }) ).uris[0] + + /* + * Wait for app view to have received the post(s). If this fails, it's + * ok, because the post _was_ actually published above. + */ try { - await whenAppViewReady(agent, postUri, res => { - const postedThread = res?.data?.thread - return AppBskyFeedDefs.isThreadViewPost(postedThread) - }) + if (postUri) { + logger.info(`composer: waiting for app view`) + + const posts = await retry( + 5, + _e => true, + async () => { + const res = await agent.app.bsky.unspecced.getPostThreadV2({ + anchor: postUri!, + above: false, + below: thread.posts.length - 1, + branchingFactor: 1, + }) + if (res.data.thread.length !== thread.posts.length) { + throw new Error(`composer: app view is not ready`) + } + if ( + !res.data.thread.every(p => + AppBskyUnspeccedDefs.isThreadItemPost(p.value), + ) + ) { + throw new Error(`composer: app view returned non-post items`) + } + return res.data.thread + }, + 1e3, + ) + postSuccessData = { + replyToUri: replyTo?.uri, + posts, + } + } } catch (waitErr: any) { - logger.error(waitErr, { - message: `Waiting for app view failed`, + logger.info(`composer: waiting for app view failed`, { + safeMessage: waitErr, }) - // Keep going because the post *was* published. } } catch (e: any) { logger.error(e, { @@ -465,12 +504,14 @@ export const ComposePost = ({ quotedThread.post.quoteCount !== initQuote.quoteCount ) { onPost?.(postUri) + onPostSuccess?.(postSuccessData) return true } return false }) } else { onPost?.(postUri) + onPostSuccess?.(postSuccessData) } onClose() Toast.show( @@ -489,6 +530,7 @@ export const ComposePost = ({ langPrefs.postLanguage, onClose, onPost, + onPostSuccess, initQuote, replyTo, setLangPrefs, @@ -782,6 +824,8 @@ let ComposerPost = React.memo(function ComposerPost({ [post.id, onSelectVideo, onImageAdd, _], ) + useHideKeyboardOnBackground() + return ( {embed.quote?.uri ? ( - - + + {canRemoveQuote && ( - dispatch({type: 'embed_remove_quote'})} /> + dispatch({type: 'embed_remove_quote'})} + style={{top: 16}} + /> )} @@ -1482,6 +1530,18 @@ function isEmptyPost(post: PostDraft) { ) } +function useHideKeyboardOnBackground() { + const appState = useAppState() + + useEffect(() => { + if (isIOS) { + if (appState === 'inactive') { + Keyboard.dismiss() + } + } + }, [appState]) +} + const styles = StyleSheet.create({ topbarInner: { flexDirection: 'row', diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 0ced143597..acab84f659 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -13,12 +13,13 @@ import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {type ComposerOptsPostRef} from '#/state/shell/composer' -import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme, web} from '#/alf' +import {QuoteEmbed} from '#/components/Post/Embed' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {parseEmbed} from '#/types/bsky/post' export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const t = useTheme() @@ -51,6 +52,12 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { } return null }, [embed]) + const parsedQuoteEmbed = quoteEmbed + ? parseEmbed({ + $type: 'app.bsky.embed.record#view', + ...quoteEmbed, + }) + : null const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { @@ -124,7 +131,9 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { )} - {showFull && quoteEmbed && } + {showFull && parsedQuoteEmbed && parsedQuoteEmbed.type === 'post' && ( + + )} ) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index d819b28b72..e4bdabac32 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -1,19 +1,20 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {type StyleProp, View, type ViewStyle} from 'react-native' import {cleanError} from '#/lib/strings/errors' import { useResolveGifQuery, useResolveLinkQuery, } from '#/state/queries/resolve-link' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' -import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' +import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed' +import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' +import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' -import {MaybeFeedCard, MaybeListCard} from '../util/post-embeds' export const ExternalEmbedGif = ({ onRemove, @@ -44,7 +45,7 @@ export const ExternalEmbedGif = ({ {linkInfo ? ( - + ) : error ? ( @@ -80,7 +81,7 @@ export const ExternalEmbedLink = ({ if (data) { if (data.type === 'external') { return ( - ) } else if (data.kind === 'feed') { - return + return ( + + ) } else if (data.kind === 'list') { - return + return ( + + ) } else if (data.kind === 'starter-pack') { return } diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx index 92102f8478..1e363d0184 100644 --- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -2,22 +2,27 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) { +export function ExternalEmbedRemoveBtn({ + onRemove, + style, +}: {onRemove: () => void} & ViewStyleProp) { + const t = useTheme() const {_} = useLingui() return ( - + diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index 4d2539c4e3..ceee17eaa0 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -6,23 +6,23 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' import { - EmbedPlayerParams, + type EmbedPlayerParams, parseEmbedPlayerFromUrl, } from '#/lib/strings/embed-player' import {isAndroid} from '#/platform/detection' import {useResolveGifQuery} from '#/state/queries/resolve-link' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {Text} from '#/components/Typography' -import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' export function GifAltTextDialog({ diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 9548ed0655..902d89b7bb 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -4,10 +4,10 @@ import {useLingui} from '@lingui/react' import { ADULT_CONTENT_LABELS, - AdultSelfLabel, + type AdultSelfLabel, OTHER_SELF_LABELS, - OtherSelfLabel, - SelfLabel, + type OtherSelfLabel, + type SelfLabel, } from '#/lib/moderation' import {isWeb} from '#/platform/detection' import {atoms as a, native, useTheme, web} from '#/alf' diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index c0ce32af31..724149937c 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {ImageStyle, useWindowDimensions, View} from 'react-native' +import {type ImageStyle, useWindowDimensions, View} from 'react-native' import {Image} from 'expo-image' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -7,12 +7,12 @@ import {useLingui} from '@lingui/react' import {MAX_ALT_TEXT} from '#/lib/constants' import {enforceLen} from '#/lib/strings/helpers' import {isAndroid, isWeb} from '#/platform/detection' -import {ComposerImage} from '#/state/gallery' +import {type ComposerImage} from '#/state/gallery' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {Text} from '#/components/Typography' diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 1c9440eb16..8bd1aa27b0 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -8,7 +8,7 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {isMobileWeb, isNative} from '#/platform/detection' -import {ComposerImage, createComposerImage} from '#/state/gallery' +import {type ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 6f5e812ed2..f927015af9 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,5 +1,5 @@ import React, { - ComponentProps, + type ComponentProps, forwardRef, useCallback, useMemo, @@ -7,16 +7,16 @@ import React, { useState, } from 'react' import { - NativeSyntheticEvent, + type NativeSyntheticEvent, Text as RNText, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, + type TextInput as RNTextInput, + type TextInputSelectionChangeEventData, View, } from 'react-native' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import PasteInput, { - PastedFile, - PasteInputRef, + type PastedFile, + type PasteInputRef, // @ts-expect-error no types when installing from github } from '@mattermost/react-native-paste-input' import {POST_IMG_MAX} from '#/lib/constants' @@ -27,7 +27,7 @@ import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' import {useTheme} from '#/lib/ThemeContext' import {isAndroid, isNative} from '#/platform/detection' import { - LinkFacetMatch, + type LinkFacetMatch, suggestLinkCardUri, } from '#/view/com/composer/text-input/text-input-util' import {atoms as a, useAlf} from '#/alf' diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index e48c2cc893..7a40604f43 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' import {msg} from '@lingui/macro' @@ -56,13 +55,8 @@ export function HomeHeaderLayoutMobile({ { - emitSoftReset() - }} - onPressIn={() => { - playHaptic('Heavy') - }} - onPressOut={() => { playHaptic('Light') + emitSoftReset() }}> @@ -72,7 +66,7 @@ export function HomeHeaderLayoutMobile({ {hasSession && ( diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx deleted file mode 100644 index cb1552fe57..0000000000 --- a/src/view/com/modals/EditProfile.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import {useCallback, useState} from 'react' -import { - ActivityIndicator, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import Animated, {FadeOut} from 'react-native-reanimated' -import {LinearGradient} from 'expo-linear-gradient' -import {type AppBskyActorDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {compressIfNeeded} from '#/lib/media/manip' -import {type PickerImage} from '#/lib/media/picker.shared' -import {cleanError} from '#/lib/strings/errors' -import {enforceLen} from '#/lib/strings/helpers' -import {colors, gradients, s} from '#/lib/styles' -import {useTheme} from '#/lib/ThemeContext' -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {useProfileUpdateMutation} from '#/state/queries/profile' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {EditableUserAvatar} from '#/view/com/util/UserAvatar' -import {UserBanner} from '#/view/com/util/UserBanner' -import {Admonition} from '#/components/Admonition' -import {InlineLinkText} from '#/components/Link' -import {useSimpleVerificationState} from '#/components/verification' -import {ErrorMessage} from '../util/error/ErrorMessage' - -const AnimatedTouchableOpacity = - Animated.createAnimatedComponent(TouchableOpacity) - -export const snapPoints = ['fullscreen'] - -export function Component({ - profile, - onUpdate, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed - onUpdate?: () => void -}) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const {closeModal} = useModalControls() - const updateMutation = useProfileUpdateMutation() - const [imageError, setImageError] = useState('') - const initialDisplayName = profile.displayName || '' - const [displayName, setDisplayName] = useState( - profile.displayName || '', - ) - const [description, setDescription] = useState( - profile.description || '', - ) - const [userBanner, setUserBanner] = useState( - profile.banner, - ) - const [userAvatar, setUserAvatar] = useState( - profile.avatar, - ) - const [newUserBanner, setNewUserBanner] = useState< - PickerImage | undefined | null - >() - const [newUserAvatar, setNewUserAvatar] = useState< - PickerImage | undefined | null - >() - const onPressCancel = () => { - closeModal() - } - const onSelectNewAvatar = useCallback( - async (img: PickerImage | null) => { - setImageError('') - if (img === null) { - setNewUserAvatar(null) - setUserAvatar(null) - return - } - try { - const finalImg = await compressIfNeeded(img, 1000000) - setNewUserAvatar(finalImg) - setUserAvatar(finalImg.path) - } catch (e: any) { - setImageError(cleanError(e)) - } - }, - [setNewUserAvatar, setUserAvatar, setImageError], - ) - - const onSelectNewBanner = useCallback( - async (img: PickerImage | null) => { - setImageError('') - if (!img) { - setNewUserBanner(null) - setUserBanner(null) - return - } - try { - const finalImg = await compressIfNeeded(img, 1000000) - setNewUserBanner(finalImg) - setUserBanner(finalImg.path) - } catch (e: any) { - setImageError(cleanError(e)) - } - }, - [setNewUserBanner, setUserBanner, setImageError], - ) - - const onPressSave = useCallback(async () => { - setImageError('') - try { - await updateMutation.mutateAsync({ - profile, - updates: { - displayName, - description, - }, - newUserAvatar, - newUserBanner, - }) - Toast.show(_(msg({message: 'Profile updated', context: 'toast'}))) - onUpdate?.() - closeModal() - } catch (e: any) { - logger.error('Failed to update user profile', {message: String(e)}) - } - }, [ - updateMutation, - profile, - onUpdate, - closeModal, - displayName, - description, - newUserAvatar, - newUserBanner, - setImageError, - _, - ]) - const verification = useSimpleVerificationState({ - profile, - }) - - return ( - - - - Edit my profile - - - - - - - - {updateMutation.isError && ( - - - - )} - {imageError !== '' && ( - - - - )} - - - - Display Name - - - setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) - } - accessible={true} - accessibilityLabel={_(msg`Display name`)} - accessibilityHint={_(msg`Edit your display name`)} - /> - - {verification.isVerified && - verification.role === 'default' && - displayName !== initialDisplayName && ( - - - - You are verified. You will lose your verification status - if you change your display name.{' '} - - Learn more. - - - - - )} - - - - Description - - setDescription(enforceLen(v, MAX_DESCRIPTION))} - accessible={true} - accessibilityLabel={_(msg`Description`)} - accessibilityHint={_(msg`Edit your profile description`)} - /> - - {updateMutation.isPending ? ( - - - - ) : ( - - - - Save Changes - - - - )} - {!updateMutation.isPending && ( - - - - Cancel - - - - )} - - - - ) -} - -const styles = StyleSheet.create({ - title: { - textAlign: 'center', - fontWeight: '600', - fontSize: 24, - marginBottom: 18, - }, - label: { - fontWeight: '600', - paddingHorizontal: 4, - paddingBottom: 4, - marginTop: 20, - }, - form: { - paddingHorizontal: 14, - }, - textInput: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 14, - paddingVertical: 10, - fontSize: 16, - }, - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 12, - paddingTop: 10, - fontSize: 16, - height: 120, - textAlignVertical: 'top', - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 10, - marginBottom: 10, - }, - avi: { - position: 'absolute', - top: 80, - left: 24, - width: 84, - height: 84, - borderWidth: 2, - borderRadius: 42, - }, - photos: { - marginBottom: 36, - marginHorizontal: -14, - }, - errorContainer: {marginTop: 20}, -}) diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx deleted file mode 100644 index b0bf76ede1..0000000000 --- a/src/view/com/modals/LinkWarning.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import {SafeAreaView, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {shareUrl} from '#/lib/sharing' -import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers' -import {colors, s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from './util' - -export const snapPoints = ['50%'] - -export function Component({ - text, - href, - share, -}: { - text: string - href: string - share?: boolean -}) { - const pal = usePalette('default') - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const {_} = useLingui() - const potentiallyMisleading = isPossiblyAUrl(text) - const openLink = useOpenLink() - - const onPressVisit = () => { - closeModal() - if (share) { - shareUrl(href) - } else { - openLink(href, false, true) - } - } - - return ( - - - - {potentiallyMisleading ? ( - <> - - - Potentially Misleading Link - - - ) : ( - - Leaving Bluesky - - )} - - - - - This link is taking you to the following website: - - - - - {potentiallyMisleading && ( - - Make sure this is where you intend to go! - - )} - - - -