diff --git a/CLAUDE.md b/CLAUDE.md index 5192660ea4..a15b5bad88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -431,16 +431,30 @@ yarn intl:compile # Compile translations for runtime // src/state/queries/profile.ts import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -// Query key pattern -const RQKEY_ROOT = 'profile' -export const RQKEY = (did: string) => [RQKEY_ROOT, did] +import {createQueryKey} from '#/state/queries/util' -// Query hook +/* + * Query key name should match the query hook name for consistency + */ +const profileQueryKeyRoot = 'profile' + +/* + * Use object params and createQueryKey helper for better readability and to + * avoid bugs with parameter order or types. + */ +export const createProfileQueryKey = (args: {did: string}) => + createQueryKey(profileQueryKeyRoot, args) + +/* + * Query hook should be named use[Name]Query, where [Name] describes the data + * being fetched. This is not a strict requirement, but it's a helpful + * convention for discoverability + */ export function useProfileQuery({did}: {did: string}) { const agent = useAgent() return useQuery({ - queryKey: RQKEY(did), + queryKey: createProfileQueryKey({did}), queryFn: async () => { const res = await agent.getProfile({actor: did}) return res.data @@ -450,8 +464,12 @@ export function useProfileQuery({did}: {did: string}) { }) } -// Mutation hook -export function useUpdateProfile() { +/* + * Mutation hook should match the name of the query hook, but with "Mutation" + * suffix. This is not a strict requirement, but it's a helpful convention for + * discoverability and consistency. + */ +export function useProfileMutation() { const queryClient = useQueryClient() return useMutation({ @@ -459,7 +477,9 @@ export function useUpdateProfile() { // Update logic }, onSuccess: (_, variables) => { - queryClient.invalidateQueries({queryKey: RQKEY(variables.did)}) + queryClient.invalidateQueries({ + queryKey: createProfileQueryKey({did: variables.did}), + }) }, onError: (error) => { if (isNetworkError(error)) { @@ -473,6 +493,24 @@ export function useUpdateProfile() { } }) } + +/* + * If cache mutation is needed, include specific interfaces for the specific + * mutations you require adjacent to the source queries. Naming should be + * descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is + * not a strict requirement, but it's a helpful convention for discoverability + * and consistency. + */ +export function useProfileCacheMutation() { + const queryClient = useQueryClient() + + return (data: Partial) => { + queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => { + if (!oldData) return oldData + return {...oldData, ...data} + }) + } +} ``` **Stale Time Constants** (from `src/state/queries/index.ts`): @@ -491,7 +529,7 @@ export function useDraftsQuery() { const agent = useAgent() return useInfiniteQuery({ - queryKey: ['drafts'], + queryKey: createQueryKey('drafts'), queryFn: async ({pageParam}) => { const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam}) return res.data @@ -504,6 +542,19 @@ export function useDraftsQuery() { To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []` +**Persisted Queries** + +To persist query data across app restarts, `createQueryKey` supports a third +parameter called `options`, which has a `persistedVersion` property. When this +property is set to a number, the query will be persisted. + +When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid. + +```tsx +export const createProfileQueryKey = (args: {did: string}) => + createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1}) +``` + ### Preferences (React Context) ```tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index 21cdd850a5..a0f0db9140 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -47,7 +47,6 @@ export default defineConfig( js.configs.recommended, tseslint.configs.recommendedTypeChecked, reactHooks.configs.flat.recommended, - // @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439 importX.flatConfigs.recommended, importX.flatConfigs.typescript, importX.flatConfigs['react-native'], @@ -62,6 +61,7 @@ export default defineConfig( 'react-native': reactNative, 'react-native-a11y': reactNativeA11y, 'simple-import-sort': simpleImportSort, + // @ts-expect-error - not sure why lingui, 'react-compiler': reactCompiler, 'bsky-internal': bskyInternal, @@ -127,6 +127,7 @@ export default defineConfig( */ ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, + 'react/hook-use-state': 'warn', 'react/no-unescaped-entities': 'off', 'react/prop-types': 'off', 'react-native/no-inline-styles': 'off', @@ -189,6 +190,18 @@ export default defineConfig( */ ignore: ['^#\/locale\/locales\/.+\/messages'], }], + 'import-x/no-extraneous-dependencies': ['error', { + 'whitelist': [ + // test files only + '@jest/globals', + // we only use a really simple util from this, and we know it will be present + 'expo-modules-core', + // this is a dep for @atproto/api, but we absolutely need them in sync, so just + // rely on the transient version + '@atproto/common-web', + ] + }], + 'import-x/no-nodejs-modules': 'error', /** * TypeScript-specific rules diff --git a/eslint/avoid-unwrapped-text.js b/eslint/avoid-unwrapped-text.js index a963a94923..206617f191 100644 --- a/eslint/avoid-unwrapped-text.js +++ b/eslint/avoid-unwrapped-text.js @@ -29,6 +29,7 @@ function getTagName(node) { return reversedIdentifiers.reverse().join('.') } +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js index 07f6c11e55..6ba2df5d6c 100644 --- a/eslint/use-exact-imports.js +++ b/eslint/use-exact-imports.js @@ -3,6 +3,7 @@ const BANNED_IMPORTS = [ '@fortawesome/free-solid-svg-icons', ] +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'suggestion', diff --git a/eslint/use-prefixed-imports.js b/eslint/use-prefixed-imports.js index 64a7b12f25..8c04803849 100644 --- a/eslint/use-prefixed-imports.js +++ b/eslint/use-prefixed-imports.js @@ -10,6 +10,7 @@ const BANNED_IMPORT_PREFIXES = [ 'view/', ] +/** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'suggestion', diff --git a/jest/jestSetup.js b/jest/jestSetup.js index f9bc36f6bf..6a6987c79d 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -9,6 +9,7 @@ jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock'), ) jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => { + // eslint-disable-next-line import-x/no-nodejs-modules const {EventEmitter} = require('events') return { __esModule: true, diff --git a/jest/test-utils.tsx b/jest/test-utils.tsx deleted file mode 100644 index 264b31fae5..0000000000 --- a/jest/test-utils.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {SafeAreaProvider} from 'react-native-safe-area-context' -import {render} from '@testing-library/react-native' - -import {ThemeProvider} from '../src/lib/ThemeContext' -import {type RootStoreModel, RootStoreProvider} from '../src/state' - -const customRender = (ui: any, rootStore: RootStoreModel) => - render( - - - - {ui} - - - , - ) - -// re-export everything -export * from '@testing-library/react-native' - -// override render method -export {customRender as render} diff --git a/package.json b/package.json index f3bd629126..64db709ddd 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.19.3", + "@atproto/api": "^0.19.5", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", @@ -111,7 +111,6 @@ "@ipld/dag-cbor": "^9.2.0", "@lingui/core": "^5.9.2", "@lingui/react": "^5.9.2", - "@mattermost/react-native-paste-input": "mattermost/react-native-paste-input", "@miblanchard/react-native-slider": "^2.6.0", "@mozzius/expo-dynamic-app-icon": "^1.8.0", "@react-native-async-storage/async-storage": "2.2.0", @@ -119,9 +118,9 @@ "@react-navigation/native": "^7.1.33", "@react-navigation/native-stack": "^7.14.4", "@sentry/react-native": "~6.20.0", - "@tanstack/query-async-storage-persister": "^5.25.0", - "@tanstack/react-query": "5.25.0", - "@tanstack/react-query-persist-client": "^5.25.0", + "@tanstack/query-async-storage-persister": "^5.96.2", + "@tanstack/react-query": "^5.96.2", + "@tanstack/react-query-persist-client": "^5.96.2", "@tiptap/core": "^2.9.1", "@tiptap/extension-document": "^2.9.1", "@tiptap/extension-hard-break": "^2.9.1", @@ -169,6 +168,7 @@ "expo-location": "~19.0.8", "expo-media-library": "~18.2.1", "expo-notifications": "~0.32.16", + "expo-paste-input": "^0.1.12", "expo-privacy-sensitive": "^0.1.0", "expo-screen-orientation": "~9.0.8", "expo-sharing": "~14.0.8", @@ -202,6 +202,7 @@ "react": "19.1.0", "react-compiler-runtime": "^19.1.0-rc.1", "react-dom": "19.1.0", + "react-hotkeys-hook": "5.2.4", "react-image-crop": "^11.0.7", "react-is": "19", "react-keyed-flatten-children": "^5.0.0", @@ -249,7 +250,6 @@ "@lingui/cli": "^5.9.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@react-native/babel-preset": "0.81.5", - "@react-native/eslint-config": "^0.81.5", "@react-native/typescript-config": "^0.81.5", "@sentry/webpack-plugin": "^3.2.2", "@testing-library/react-native": "^13.2.0", @@ -267,8 +267,8 @@ "eslint": "^9.39.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-bsky-internal": "link:./eslint", - "eslint-plugin-import-x": "^4.16.1", - "eslint-plugin-lingui": "^0.11.0", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-lingui": "^0.12.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-hooks": "^7.0.1", @@ -290,7 +290,7 @@ "svgo": "^3.3.2", "ts-plugin-sort-import-suggestions": "^1.0.4", "typescript": "^6.0.2", - "typescript-eslint": "^8.57.2", + "typescript-eslint": "^8.58.0", "webpack-bundle-analyzer": "^4.10.1" }, "resolutions": { diff --git a/patches/@mattermost+react-native-paste-input+0.8.1.patch b/patches/@mattermost+react-native-paste-input+0.8.1.patch deleted file mode 100644 index c0a895715e..0000000000 --- a/patches/@mattermost+react-native-paste-input+0.8.1.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt -index 4ed2307..ede1181 100644 ---- a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt -+++ b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt -@@ -54,7 +54,7 @@ class PasteTextInputManager(context: ReactApplicationContext) : ReactTextInputMa - } - - override fun getExportedCustomBubblingEventTypeConstants(): MutableMap { -- val map = super.getExportedCustomBubblingEventTypeConstants()!! -+ val map = super.getExportedCustomBubblingEventTypeConstants().toMutableMap() - map["onPaste"] = MapBuilder.of( - "phasedRegistrationNames", - MapBuilder.of("bubbled", "onPaste") diff --git a/patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled b/patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled deleted file mode 100644 index a7f1461432..0000000000 --- a/patches/@mattermost+react-native-paste-input+0.8.1.patch.disabled +++ /dev/null @@ -1,264 +0,0 @@ -diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m -index e916023..5049c33 100644 ---- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m -+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m -@@ -4,6 +4,7 @@ - // - // Created by Elias Nahum on 04-11-20. - // Copyright © 2020 Facebook. All rights reserved. -+// Updated to remove parent’s default text view - // - - #import "PasteInputView.h" -@@ -12,49 +13,78 @@ - - @implementation PasteInputView - { -- PasteInputTextView *_backedTextInputView; -+ // We'll store the custom text view in this ivar -+ PasteInputTextView *_customBackedTextView; - } - - - (instancetype)initWithBridge:(RCTBridge *)bridge - { -+ // Must call the super’s designated initializer - if (self = [super initWithBridge:bridge]) { -- _backedTextInputView = [[PasteInputTextView alloc] initWithFrame:self.bounds]; -- _backedTextInputView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; -- _backedTextInputView.textInputDelegate = self; -+ // 1. The parent (RCTMultilineTextInputView) has already created -+ // its own _backedTextInputView = [RCTUITextView new] in super init. -+ // We can remove that subview: - -- [self addSubview:_backedTextInputView]; -- } -+ id parentInputView = super.backedTextInputView; -+ if ([parentInputView isKindOfClass:[UIView class]]) { -+ UIView *parentSubview = (UIView *)parentInputView; -+ if (parentSubview.superview == self) { -+ [parentSubview removeFromSuperview]; -+ } -+ } - -+ // 2. Now create our custom PasteInputTextView -+ _customBackedTextView = [[PasteInputTextView alloc] initWithFrame:self.bounds]; -+ _customBackedTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; -+ _customBackedTextView.textInputDelegate = self; -+ -+ // Optional: disable inline predictions for iOS 17+ -+ if (@available(iOS 17.0, *)) { -+ _customBackedTextView.inlinePredictionType = UITextInlinePredictionTypeNo; -+ } -+ -+ // 3. Add your custom text view as the only subview -+ [self addSubview:_customBackedTextView]; -+ } - return self; - } - -+/** -+ * Override the parent's accessor so that anywhere in RN that calls -+ * `self.backedTextInputView` will get the custom PasteInputTextView. -+ */ - - (id)backedTextInputView - { -- return _backedTextInputView; -+ return _customBackedTextView; - } - --- (void)setDisableCopyPaste:(BOOL)disableCopyPaste { -- _backedTextInputView.disableCopyPaste = disableCopyPaste; -+#pragma mark - Setters for React Props -+ -+- (void)setDisableCopyPaste:(BOOL)disableCopyPaste -+{ -+ _customBackedTextView.disableCopyPaste = disableCopyPaste; - } - --- (void)setOnPaste:(RCTDirectEventBlock)onPaste { -- _backedTextInputView.onPaste = onPaste; -+- (void)setOnPaste:(RCTDirectEventBlock)onPaste -+{ -+ _customBackedTextView.onPaste = onPaste; - } - --- (void)setSmartPunctuation:(NSString *)smartPunctuation { -- if ([smartPunctuation isEqualToString:@"enable"]) { -- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeYes]; -- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeYes]; -- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes]; -- } else if ([smartPunctuation isEqualToString:@"disable"]) { -- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeNo]; -- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeNo]; -- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo]; -- } else { -- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeDefault]; -- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeDefault]; -- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault]; -- } -+- (void)setSmartPunctuation:(NSString *)smartPunctuation -+{ -+ if ([smartPunctuation isEqualToString:@"enable"]) { -+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeYes]; -+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeYes]; -+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes]; -+ } else if ([smartPunctuation isEqualToString:@"disable"]) { -+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeNo]; -+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeNo]; -+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo]; -+ } else { -+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeDefault]; -+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeDefault]; -+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault]; -+ } - } - - #pragma mark - UIScrollViewDelegate -@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation { - - (void)scrollViewDidScroll:(UIScrollView *)scrollView - { - RCTDirectEventBlock onScroll = self.onScroll; -- - if (onScroll) { - CGPoint contentOffset = scrollView.contentOffset; - CGSize contentSize = scrollView.contentSize; -@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView - - onScroll(@{ - @"contentOffset": @{ -- @"x": @(contentOffset.x), -- @"y": @(contentOffset.y) -+ @"x": @(contentOffset.x), -+ @"y": @(contentOffset.y) - }, - @"contentInset": @{ -- @"top": @(contentInset.top), -- @"left": @(contentInset.left), -- @"bottom": @(contentInset.bottom), -- @"right": @(contentInset.right) -+ @"top": @(contentInset.top), -+ @"left": @(contentInset.left), -+ @"bottom": @(contentInset.bottom), -+ @"right": @(contentInset.right) - }, - @"contentSize": @{ -- @"width": @(contentSize.width), -- @"height": @(contentSize.height) -+ @"width": @(contentSize.width), -+ @"height": @(contentSize.height) - }, - @"layoutMeasurement": @{ -- @"width": @(size.width), -- @"height": @(size.height) -+ @"width": @(size.width), -+ @"height": @(size.height) - }, - @"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/src/App.native.tsx b/src/App.native.tsx index 6f6a2220c9..a6ec93d313 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -11,13 +11,11 @@ import { import * as ScreenOrientation from 'expo-screen-orientation' import * as SplashScreen from 'expo-splash-screen' import * as SystemUI from 'expo-system-ui' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {QueryProvider} from '#/lib/react-query' -import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' import I18nProvider from '#/locale/i18nProvider' @@ -59,7 +57,7 @@ import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {Shell} from '#/view/shell' -import {ThemeProvider as Alf} from '#/alf' +import {atoms as a, ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as ContextMenuProvider} from '#/components/ContextMenu' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' @@ -89,9 +87,9 @@ import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -SplashScreen.preventAutoHideAsync() +void SplashScreen.preventAutoHideAsync() if (IS_IOS) { - SystemUI.setBackgroundColorAsync('black') + void SystemUI.setBackgroundColorAsync('black') } if (IS_ANDROID) { // iOS is handled by the config plugin -sfn @@ -105,17 +103,17 @@ if (IS_ANDROID) { /** * Begin geolocation ASAP */ -Geo.resolve() -prefetchAgeAssuranceConfig() -prefetchLiveEvents() -prefetchAppConfig() +void Geo.resolve() +void prefetchAgeAssuranceConfig() +void prefetchLiveEvents() +void prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const theme = useColorModeTheme() - const {_} = useLingui() + const {t: l} = useLingui() const hasCheckedReferrer = useStarterPackEntry() // init @@ -134,16 +132,16 @@ function InnerApp() { } } const account = readLastActiveAccount() - onLaunch(account) + void onLaunch(account) }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), { + Toast.show(l`Sorry! Your session expired. Please sign in again.`, { type: 'info', }) }) - }, [_]) + }, [l]) return ( @@ -176,7 +174,7 @@ function InnerApp() { + style={a.h_full}> @@ -220,8 +218,8 @@ function App() { const [isReady, setReady] = useState(false) useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => - setReady(true), + void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( + () => setReady(true), ) }, []) diff --git a/src/App.web.tsx b/src/App.web.tsx index fc2a5b1650..c391d69925 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -5,10 +5,10 @@ import './style.css' import {Fragment, useEffect, useState} from 'react' import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller' import {SafeAreaProvider} from 'react-native-safe-area-context' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' +import {Provider as HotkeysProvider} from '#/lib/hotkeys' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' @@ -82,17 +82,17 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom /** * Begin geolocation ASAP */ -Geo.resolve() -prefetchAgeAssuranceConfig() -prefetchLiveEvents() -prefetchAppConfig() +void Geo.resolve() +void prefetchAgeAssuranceConfig() +void prefetchLiveEvents() +void prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const theme = useColorModeTheme() - const {_} = useLingui() + const {t: l} = useLingui() const hasCheckedReferrer = useStarterPackEntry() // init @@ -105,22 +105,22 @@ function InnerApp() { await features.init } } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) + logger.error('session: resumeSession failed', {message: e}) } finally { setIsReady(true) } } const account = readLastActiveAccount() - onLaunch(account) + void onLaunch(account) }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), { + Toast.show(l`Sorry! Your session expired. Please sign in again.`, { type: 'info', }) }) - }, [_]) + }, [l]) return ( @@ -156,8 +156,10 @@ function InnerApp() { - - + + + + @@ -195,8 +197,8 @@ function App() { const [isReady, setReady] = useState(false) useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => - setReady(true), + void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( + () => setReady(true), ) }, []) diff --git a/src/alf/typography.tsx b/src/alf/typography.tsx index d79ad81ead..9b750f1537 100644 --- a/src/alf/typography.tsx +++ b/src/alf/typography.tsx @@ -1,12 +1,14 @@ import {Children} from 'react' -import {type TextProps as RNTextProps} from 'react-native' -import {type StyleProp, type TextStyle} from 'react-native' +import { + type StyleProp, + type TextProps as RNTextProps, + type TextStyle, +} from 'react-native' import {UITextView} from 'react-native-uitextview' import createEmojiRegex from 'emoji-regex' import {type Alf, applyFonts, atoms, flatten} from '#/alf' -import {IS_NATIVE} from '#/env' -import {IS_IOS} from '#/env' +import {IS_IOS, IS_NATIVE} from '#/env' /** * Ensures that `lineHeight` defaults to a relative value of `1`, or applies @@ -107,7 +109,8 @@ export function renderChildrenWithEmoji( }) } -const SINGLE_EMOJI_RE = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u +const SINGLE_EMOJI_RE = + /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]+$/u export function isOnlyEmoji(text: string) { return text.length <= 15 && SINGLE_EMOJI_RE.test(text) } diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index 3d39f464cd..d6b1f55c81 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -110,6 +110,7 @@ const Context = createContext({ }, }, }) +Context.displayName = 'AnalyticsContext' /** * Ensures that deviceId is set and migrated from legacy storage. Handled on diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 645b90a4f6..ff23e3998f 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -560,32 +560,30 @@ export function ProfileGrid({ Suggested for you - {!isProfileHeaderContext && ( - - )} + @@ -605,16 +603,14 @@ export function ProfileGrid({ decelerationRate="fast"> {content} - {!isProfileHeaderContext && ( - { - followDialogControl.open() - ax.metric('suggestedUser:seeMore', { - logContext, - }) - }} - /> - )} + { + followDialogControl.open() + ax.metric('suggestedUser:seeMore', { + logContext, + }) + }} + /> )} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index 209ba10e72..946df787e7 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -13,6 +13,7 @@ import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {KeepAwake} from '#/components/KeepAwake' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {GifPresentationControls} from '../GifPresentationControls' @@ -112,6 +113,7 @@ export function VideoEmbedInnerNative({ /> )} + ) } diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx index 2e56d13f4f..ba6ad18ff8 100644 --- a/src/components/PostControls/BookmarkButton.tsx +++ b/src/components/PostControls/BookmarkButton.tsx @@ -136,6 +136,8 @@ export const BookmarkButton = memo(function BookmarkButton({ - + ) }) diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx index 9a24eb9173..3ea85e2811 100644 --- a/src/components/PostControls/PostControlButton.tsx +++ b/src/components/PostControls/PostControlButton.tsx @@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) { { const ax = useAnalytics() + const t = useTheme() const {t: l} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() @@ -270,6 +271,8 @@ let PostControls = ({ requireAuth(() => onPressToggleLike())} label={ post.viewer?.like @@ -296,10 +299,14 @@ let PostControls = ({ hasBeenToggled={hasLikeIconBeenToggled} /> ( + + {formatPostStatCount(count)} + + )} /> diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx index dd0cedfe8f..22c682bbba 100644 --- a/src/components/ProfileBadges.tsx +++ b/src/components/ProfileBadges.tsx @@ -1,7 +1,7 @@ -import {View} from 'react-native' +import {useWindowDimensions, View} from 'react-native' import {useProfileShadow} from '#/state/cache/profile-shadow' -import {atoms as a, type ViewStyleProp} from '#/alf' +import {atoms as a, useAlf, type ViewStyleProp} from '#/alf' import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' @@ -38,12 +38,21 @@ export function ProfileBadges({ }) { const shadowed = useProfileShadow(profile) const verification = useSimpleVerificationState({profile}) + const {fontScale: nativeScaleMultiplier} = useWindowDimensions() + const { + fonts: {scaleMultiplier: alfScaleMultiplier}, + } = useAlf() // if nothing to show, don't render the container at all if (!verification.showBadge && !isBotAccount(shadowed)) return null const isOnTheSmallSide = size === 'xs' || size === 'sm' + const verificationIconWidth = + verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier + const botIconWidth = + botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier + return ( - + ) : ( <> {verification.showBadge && ( )} - + )} diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx index 67c86f7dc5..1aa4b39ea1 100644 --- a/src/components/Select/index.web.tsx +++ b/src/components/Select/index.web.tsx @@ -3,8 +3,7 @@ import {View} from 'react-native' import {Select as RadixSelect} from 'radix-ui' import {useA11y} from '#/state/a11y' -import {flatten, useTheme, web} from '#/alf' -import {atoms as a} from '#/alf' +import {atoms as a, flatten, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { @@ -109,7 +108,7 @@ export function Trigger({children, label}: TriggerProps) { borderRadius: 10, maxWidth: 400, outline: 0, - borderWidth: 2, + borderWidth: 1, borderStyle: 'solid', borderColor: focused ? t.palette.primary_500 diff --git a/src/components/StarterPack/Main/PostsList.tsx b/src/components/StarterPack/Main/PostsList.tsx index e9d7a9a9a2..ebd0dcf59d 100644 --- a/src/components/StarterPack/Main/PostsList.tsx +++ b/src/components/StarterPack/Main/PostsList.tsx @@ -46,6 +46,7 @@ export const PostsList = forwardRef( return ( diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index bf79f5dfc4..15e3a2b472 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -1,6 +1,6 @@ import {useState} from 'react' import {View} from 'react-native' -import {XRPCError} from '@atproto/xrpc' +import {XRPCError} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts index 4fa171cad5..d2c3810208 100644 --- a/src/components/dialogs/EmailDialog/events.ts +++ b/src/components/dialogs/EmailDialog/events.ts @@ -1,5 +1,5 @@ import {useEffect} from 'react' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' const events = new EventEmitter<{ emailVerified: void diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx index 24344b4417..325805ed69 100644 --- a/src/components/forms/DateField/index.shared.tsx +++ b/src/components/forms/DateField/index.shared.tsx @@ -63,7 +63,7 @@ export function DateFieldButton({ paddingLeft: 14, paddingRight: 14, borderColor: 'transparent', - borderWidth: 2, + borderWidth: 1, }, native({ paddingTop: 10, diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx index 47829101ec..8b54b44246 100644 --- a/src/components/forms/SearchInput.tsx +++ b/src/components/forms/SearchInput.tsx @@ -1,8 +1,9 @@ -import {forwardRef} from 'react' +import {useEffect, useRef} from 'react' import {type TextInput, View} from 'react-native' import {useLingui} from '@lingui/react/macro' import {HITSLOP_10} from '#/lib/constants' +import {listenFocusSearch} from '#/state/events' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as TextField from '#/components/forms/TextField' @@ -10,73 +11,89 @@ import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {IS_NATIVE} from '#/env' -type SearchInputProps = Omit & { +type Props = Omit & { label?: TextField.InputProps['label'] /** * Called when the user presses the (X) button */ onClearText?: () => void + hotkey?: boolean + ref?: React.RefObject } -export const SearchInput = forwardRef( - function SearchInput({value, label, onClearText, ...rest}, ref) { - const t = useTheme() - const {t: l} = useLingui() - const showClear = value && value.length > 0 +export function SearchInput({ + value, + label, + onClearText, + hotkey, + ref, + ...rest +}: Props) { + const t = useTheme() + const {t: l} = useLingui() + const showClear = value && value.length > 0 + const internalRef = useRef(null) + const inputRef = ref ?? internalRef - return ( - - - - - + useEffect(() => { + if (!hotkey) return + return listenFocusSearch(() => { + inputRef.current?.focus() + }) + }, [hotkey, inputRef]) - {showClear && ( - - - - )} - - ) - }, -) + return ( + + + + + + + {showClear && ( + + + + )} + + ) +} diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index c750079d1e..83fe2e4019 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -1,7 +1,7 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api' -import {XRPCError} from '@atproto/xrpc' +import {XRPCError} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index ec6484ae6f..678fd8886a 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -184,7 +184,7 @@ function Inner(props: ReportDialogProps) { ) }) }, [ - props, + props.subject, allLabelers, state.selectedOption, isBskyOnlyReason, @@ -241,7 +241,17 @@ function Inner(props: ReportDialogProps) { } finally { setPending(false) } - }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) + }, [ + _, + submitReport, + state, + dispatch, + props.subject, + props.control, + props.onAfterSubmit, + setPending, + setSuccess, + ]) useCallOnce(() => { ax.metric('reportDialog:open', { diff --git a/src/env/index.ts b/src/env/index.ts index 14abba55a4..13ae3dcc7d 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -10,6 +10,10 @@ const iOSMajorVersion = Platform.OS === 'ios' && typeof Platform.Version === 'string' ? parseInt(Platform.Version.split('.')[0], 10) : 0 +const androidPlatformVersion = + Platform.OS === 'android' && typeof Platform.Version === 'number' + ? Platform.Version + : 0 /** * The semver version of the app, specified in our `package.json`.file. On @@ -49,3 +53,7 @@ export const IS_WEB_FIREFOX: boolean = false export const IS_HIGH_DPI: boolean = true // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26 +// So we can avoid attempting on-device translation when we know it's unsupported. +export const IS_TRANSLATION_SUPPORTED: boolean = + (IS_IOS && iOSMajorVersion >= 18) || + (IS_ANDROID && androidPlatformVersion > 22) diff --git a/src/env/index.web.ts b/src/env/index.web.ts index 0a078fdeb0..68604a2e5b 100644 --- a/src/env/index.web.ts +++ b/src/env/index.web.ts @@ -48,3 +48,4 @@ export const IS_HIGH_DPI: boolean = window.matchMedia( '(min-resolution: 2dppx)', ).matches export const IS_LIQUID_GLASS: boolean = false +export const IS_TRANSLATION_SUPPORTED: boolean = false diff --git a/src/geolocation/service.ts b/src/geolocation/service.ts index 2d9285b676..ec34747284 100644 --- a/src/geolocation/service.ts +++ b/src/geolocation/service.ts @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import {networkRetry} from '#/lib/async/retry' import { diff --git a/src/lib/custom-animations/CountWheel.tsx b/src/lib/custom-animations/CountWheel.tsx index e2c206078c..698df0e49d 100644 --- a/src/lib/custom-animations/CountWheel.tsx +++ b/src/lib/custom-animations/CountWheel.tsx @@ -8,10 +8,7 @@ import Animated, { } from 'react-native-reanimated' import {decideShouldRoll} from '#/lib/custom-animations/util' -import {s} from '#/lib/styles' -import {Text} from '#/view/com/util/text/Text' -import {atoms as a, useTheme} from '#/alf' -import {useFormatPostStatCount} from '#/components/PostControls/util' +import {atoms as a} from '#/alf' const animationConfig = { duration: 400, @@ -87,89 +84,66 @@ function ExitingDown() { } export function CountWheel({ - likeCount, - big, - isLiked, + count, + isToggled, hasBeenToggled, + renderCount, }: { - likeCount: number - big?: boolean - isLiked: boolean + count: number + isToggled: boolean hasBeenToggled: boolean + renderCount: (props: {count: number}) => React.ReactNode }) { - const t = useTheme() const shouldAnimate = !useReducedMotion() && hasBeenToggled - const shouldRoll = decideShouldRoll(isLiked, likeCount) + const shouldRoll = decideShouldRoll(isToggled, count) // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting // animation // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would // be unnecessary const [key, setKey] = useState(0) - const [prevCount, setPrevCount] = useState(likeCount) - const prevIsLiked = useRef(isLiked) - const formatPostStatCount = useFormatPostStatCount() - const formattedCount = formatPostStatCount(likeCount) - const formattedPrevCount = formatPostStatCount(prevCount) + const [prevCount, setPrevCount] = useState(count) + const prevIsToggled = useRef(isToggled) useEffect(() => { - if (isLiked === prevIsLiked.current) { + if (isToggled === prevIsToggled.current) { return } - const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 + const newPrevCount = isToggled ? count - 1 : count + 1 setKey(prev => prev + 1) setPrevCount(newPrevCount) - prevIsLiked.current = isLiked - }, [isLiked, likeCount]) + prevIsToggled.current = isToggled + }, [isToggled, count]) const enteringAnimation = shouldAnimate && shouldRoll - ? isLiked + ? isToggled ? EnteringUp : EnteringDown : undefined const exitingAnimation = shouldAnimate && shouldRoll - ? isLiked + ? isToggled ? ExitingUp : ExitingDown : undefined return ( - {likeCount > 0 ? ( + {count > 0 ? ( - - {formattedCount} - + {renderCount({count})} - {shouldAnimate && (likeCount > 1 || !isLiked) ? ( + {shouldAnimate && (count > 1 || !isToggled) ? ( - - {formattedPrevCount} - + {renderCount({count: prevCount})} ) : null} diff --git a/src/lib/custom-animations/CountWheel.web.tsx b/src/lib/custom-animations/CountWheel.web.tsx index c5ca71e9bd..446dab1dd8 100644 --- a/src/lib/custom-animations/CountWheel.web.tsx +++ b/src/lib/custom-animations/CountWheel.web.tsx @@ -3,10 +3,6 @@ import {View} from 'react-native' import {useReducedMotion} from 'react-native-reanimated' import {decideShouldRoll} from '#/lib/custom-animations/util' -import {s} from '#/lib/styles' -import {Text} from '#/view/com/util/text/Text' -import {atoms as a, useTheme} from '#/alf' -import {useFormatPostStatCount} from '#/components/PostControls/util' const animationConfig = { duration: 400, @@ -35,50 +31,46 @@ const exitingDownKeyframe = [ ] export function CountWheel({ - likeCount, - big, - isLiked, + count, + isToggled, hasBeenToggled, + renderCount, }: { - likeCount: number - big?: boolean - isLiked: boolean + count: number + isToggled: boolean hasBeenToggled: boolean + renderCount: (props: {count: number}) => React.ReactNode }) { - const t = useTheme() const shouldAnimate = !useReducedMotion() && hasBeenToggled - const shouldRoll = decideShouldRoll(isLiked, likeCount) + const shouldRoll = decideShouldRoll(isToggled, count) const countView = useRef(null) const prevCountView = useRef(null) - const [prevCount, setPrevCount] = useState(likeCount) - const prevIsLiked = useRef(isLiked) - const formatPostStatCount = useFormatPostStatCount() - const formattedCount = formatPostStatCount(likeCount) - const formattedPrevCount = formatPostStatCount(prevCount) + const [prevCount, setPrevCount] = useState(count) + const prevIsToggled = useRef(isToggled) useEffect(() => { - if (isLiked === prevIsLiked.current) { + if (isToggled === prevIsToggled.current) { return } - const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 + const newPrevCount = isToggled ? count - 1 : count + 1 if (shouldAnimate && shouldRoll) { countView.current?.animate?.( - isLiked ? enteringUpKeyframe : enteringDownKeyframe, + isToggled ? enteringUpKeyframe : enteringDownKeyframe, animationConfig, ) prevCountView.current?.animate?.( - isLiked ? exitingUpKeyframe : exitingDownKeyframe, + isToggled ? exitingUpKeyframe : exitingDownKeyframe, animationConfig, ) setPrevCount(newPrevCount) } - prevIsLiked.current = isLiked - }, [isLiked, likeCount, shouldAnimate, shouldRoll]) + prevIsToggled.current = isToggled + }, [isToggled, count, shouldAnimate, shouldRoll]) - if (likeCount < 1) { + if (count < 1) { return null } @@ -87,34 +79,15 @@ export function CountWheel({ - - {formattedCount} - + {renderCount({count})} - {shouldAnimate && (likeCount > 1 || !isLiked) ? ( + {shouldAnimate && (count > 1 || !isToggled) ? ( - - {formattedPrevCount} - + {renderCount({count: prevCount})} ) : null} diff --git a/src/lib/custom-animations/LikeIcon.tsx b/src/lib/custom-animations/LikeIcon.tsx index 025e96f4be..223f8e9f18 100644 --- a/src/lib/custom-animations/LikeIcon.tsx +++ b/src/lib/custom-animations/LikeIcon.tsx @@ -5,7 +5,6 @@ import Animated, { useReducedMotion, } from 'react-native-reanimated' -import {s} from '#/lib/styles' import {useTheme} from '#/alf' import { Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, @@ -86,7 +85,7 @@ export function AnimatedLikeIcon({ {isLiked ? ( - + ) : ( - + ) : ( {}, + disableScope: () => {}, + } +} diff --git a/src/lib/hotkeys/index.tsx b/src/lib/hotkeys/index.tsx new file mode 100644 index 0000000000..17e2c9c7bd --- /dev/null +++ b/src/lib/hotkeys/index.tsx @@ -0,0 +1,75 @@ +import {useLingui} from '@lingui/react/macro' +import { + HotkeysProvider, + useHotkeys, + useHotkeysContext, +} from 'react-hotkeys-hook' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {emitFocusSearch} from '#/state/events' +import {useSession} from '#/state/session' + +enum Hotkeys { + OPEN_COMPOSER = 'n', + FOCUS_SEARCH = 'slash', +} + +export function Provider({children}: React.PropsWithChildren) { + return ( + + {children} + + ) +} + +export {useHotkeysContext} + +function KeyboardShortcuts({children}: React.PropsWithChildren) { + useKeyboardShortcuts() + return children +} + +function useKeyboardShortcuts() { + const {openComposer} = useOpenComposer() + const {hasSession} = useSession() + const {t: l} = useLingui() + + const shouldIgnore = (requiresSession: boolean = false) => { + if (requiresSession && !hasSession) { + return true + } + + return false + } + + const handleKey = ( + callback: () => void, + options?: {requiresSession?: boolean}, + ) => { + if (shouldIgnore(options?.requiresSession)) { + return + } + callback() + } + + useHotkeys( + Hotkeys.OPEN_COMPOSER, + () => + handleKey( + () => { + openComposer({logContext: 'Other'}) + }, + { + requiresSession: true, + }, + ), + {scopes: ['global'], description: l`Compose new post`}, + [openComposer], + ) + + useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), { + scopes: ['global'], + preventDefault: true, + description: l`Focus the search field`, + }) +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 6d66fea489..2be799e261 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -15,7 +15,6 @@ import { 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 {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' @@ -322,7 +321,12 @@ export async function saveBytesToDisk( bytes: Uint8Array, type: string, ) { - const encoded = Buffer.from(bytes).toString('base64') + // ideally we'd use `bytes.toBase64()`, but that's only baseline newly available + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + const encoded = btoa(binary) return await saveToDevice(filename, encoded, type) } diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index ec657b6c41..aa8ee8ce47 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -10,7 +10,7 @@ import { import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' -import {PERSISTED_QUERY_ROOT} from '#/state/queries' +import {isQueryPersisted} from '#/state/queries/util' import * as env from '#/env' import {IS_NATIVE, IS_WEB} from '#/env' @@ -137,8 +137,7 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd { shouldDehydrateMutation: (_: any) => false, shouldDehydrateQuery: query => { - const root = String(query.queryKey[0]) - return root === PERSISTED_QUERY_ROOT + return isQueryPersisted(query.queryKey) }, } @@ -190,7 +189,10 @@ function QueryProviderInner({ }) useEffect(() => { if (IS_WEB) { - window.__TANSTACK_QUERY_CLIENT__ = queryClient + // WARNING, BROKEN + // something since v5.32.0 causes OOMs. not important + // so disable for now + // window.__TANSTACK_QUERY_CLIENT__ = queryClient } }, [queryClient]) return ( diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index f3a91e58dd..5f56cccc4d 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -1,4 +1,4 @@ -import {XRPCError} from '@atproto/xrpc' +import {XRPCError} from '@atproto/api' import {t} from '@lingui/core/macro' export function cleanError(str: any): string { diff --git a/src/lib/styles.ts b/src/lib/styles.ts index c39cbd0ce9..8500632e37 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,9 +1,4 @@ -import { - Dimensions, - type StyleProp, - StyleSheet, - type TextStyle, -} from 'react-native' +import {type StyleProp, StyleSheet, type TextStyle} from 'react-native' import {IS_WEB} from '#/env' import {type Theme, type TypographyVariant} from './ThemeContext' @@ -61,14 +56,6 @@ export const colors = { green5: '#082b03', unreadNotifBg: '#ebf6ff', - brandBlue: '#0066FF', - like: '#ec4899', -} - -export const gradients = { - blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons - blue: {start: '#5E55FB', end: colors.blue3}, // fab - blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner } /** @@ -78,57 +65,6 @@ export const s = StyleSheet.create({ // helpers footerSpacer: {height: 100}, contentContainer: {paddingBottom: 200}, - contentContainerExtra: {paddingBottom: 300}, - border0: {borderWidth: 0}, - border1: {borderWidth: 1}, - borderTop1: {borderTopWidth: 1}, - borderRight1: {borderRightWidth: 1}, - borderBottom1: {borderBottomWidth: 1}, - borderLeft1: {borderLeftWidth: 1}, - hidden: {display: 'none'}, - dimmed: {opacity: 0.5}, - - // font weights - fw600: {fontWeight: '600'}, - bold: {fontWeight: '600'}, - fw500: {fontWeight: '600'}, - semiBold: {fontWeight: '600'}, - fw400: {fontWeight: '400'}, - normal: {fontWeight: '400'}, - fw300: {fontWeight: '400'}, - light: {fontWeight: '400'}, - - // text decoration - underline: {textDecorationLine: 'underline'}, - - // font variants - tabularNum: {fontVariant: ['tabular-nums']}, - - // font sizes - f9: {fontSize: 9}, - f10: {fontSize: 10}, - f11: {fontSize: 11}, - f12: {fontSize: 12}, - f13: {fontSize: 13}, - f14: {fontSize: 14}, - f15: {fontSize: 15}, - f16: {fontSize: 16}, - f17: {fontSize: 17}, - f18: {fontSize: 18}, - - // line heights - ['lh13-1']: {lineHeight: 13}, - ['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px - ['lh14-1']: {lineHeight: 14}, - ['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px - ['lh15-1']: {lineHeight: 15}, - ['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px - ['lh16-1']: {lineHeight: 16}, - ['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px - ['lh17-1']: {lineHeight: 17}, - ['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px - ['lh18-1']: {lineHeight: 18}, - ['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px // margins mr2: {marginRight: 2}, @@ -171,74 +107,15 @@ export const s = StyleSheet.create({ pb20: {paddingBottom: 20}, px5: {paddingHorizontal: 5}, - // flex - flexRow: {flexDirection: 'row'}, - flexCol: {flexDirection: 'column'}, - flex1: {flex: 1}, - flexGrow1: {flexGrow: 1}, - alignCenter: {alignItems: 'center'}, - alignBaseline: {alignItems: 'baseline'}, - justifyCenter: {justifyContent: 'center'}, - - // position - absolute: {position: 'absolute'}, - // dimensions - w100pct: {width: '100%'}, - h100pct: {height: '100%'}, hContentRegion: IS_WEB ? {minHeight: '100%'} : {height: '100%'}, - window: { - width: Dimensions.get('window').width, - height: Dimensions.get('window').height, - }, // text align - textLeft: {textAlign: 'left'}, textCenter: {textAlign: 'center'}, - textRight: {textAlign: 'right'}, // colors white: {color: colors.white}, black: {color: colors.black}, - - gray1: {color: colors.gray1}, - gray2: {color: colors.gray2}, - gray3: {color: colors.gray3}, - gray4: {color: colors.gray4}, - gray5: {color: colors.gray5}, - - blue1: {color: colors.blue1}, - blue2: {color: colors.blue2}, - blue3: {color: colors.blue3}, - blue4: {color: colors.blue4}, - blue5: {color: colors.blue5}, - - red1: {color: colors.red1}, - red2: {color: colors.red2}, - red3: {color: colors.red3}, - red4: {color: colors.red4}, - red5: {color: colors.red5}, - - pink1: {color: colors.pink1}, - pink2: {color: colors.pink2}, - pink3: {color: colors.pink3}, - pink4: {color: colors.pink4}, - pink5: {color: colors.pink5}, - - purple1: {color: colors.purple1}, - purple2: {color: colors.purple2}, - purple3: {color: colors.purple3}, - purple4: {color: colors.purple4}, - purple5: {color: colors.purple5}, - - green1: {color: colors.green1}, - green2: {color: colors.green2}, - green3: {color: colors.green3}, - green4: {color: colors.green4}, - green5: {color: colors.green5}, - - brandBlue: {color: colors.brandBlue}, - likeColor: {color: colors.like}, }) export function lh( diff --git a/src/lib/translation/index.tsx b/src/lib/translation/index.tsx index 710c9993d1..0e7b182a62 100644 --- a/src/lib/translation/index.tsx +++ b/src/lib/translation/index.tsx @@ -1,18 +1,17 @@ import {useCallback, useContext, useEffect, useMemo, useState} from 'react' import {LayoutAnimation, Platform} from 'react-native' import {getLocales} from 'expo-localization' -import { - isTranslationSupported, - onTranslateTask, -} from '@bsky.app/expo-translate-text' +import {onTranslateTask} from '@bsky.app/expo-translate-text' import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' import {useLingui} from '@lingui/react/macro' import {useFocusEffect} from '@react-navigation/native' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' +import {codeToLanguageName} from '#/locale/helpers' import {logger} from '#/logger' +import {useLanguagePrefs} from '#/state/preferences' import {useAnalytics} from '#/analytics' -import {IS_ANDROID, IS_IOS} from '#/env' +import {IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED} from '#/env' import {Context} from './context' import { type ContextType, @@ -25,6 +24,11 @@ import {guessLanguage} from './utils' export * from './types' export * from './utils' +const E_SAME_AS_SOURCE_LANGUAGE = + 'Translation result is the same as the source text.' +const E_EMPTY_RESULT = 'Translation result is empty.' +const E_INVALID_SOURCE_LANGUAGE = 'Invalid source language' + /** * Attempts on-device translation via @bsky.app/expo-translate-text. * Uses a lazy import to avoid crashing if the native module isn't linked into @@ -80,11 +84,11 @@ async function attemptTranslation( typeof result.translatedTexts === 'string' ? result.translatedTexts : '' if (translatedText === input) { - throw new Error('Translation result is the same as the source text.') + throw new Error(E_SAME_AS_SOURCE_LANGUAGE) } if (translatedText === '') { - throw new Error('Translation result is empty.') + throw new Error(E_EMPTY_RESULT) } return { @@ -159,6 +163,7 @@ export function Provider({children}: React.PropsWithChildren) { >({}) const [refCounts, setRefCounts] = useState>({}) const ax = useAnalytics() + const langPrefs = useLanguagePrefs() const {t: l} = useLingui() const googleTranslate = useGoogleTranslate() @@ -235,7 +240,7 @@ export function Provider({children}: React.PropsWithChildren) { googleTranslate: shouldForceGoogleTranslate, }) - if (shouldForceGoogleTranslate || !isTranslationSupported()) { + if (shouldForceGoogleTranslate || !IS_TRANSLATION_SUPPORTED) { await googleTranslate( text, expectedTargetLanguage, @@ -280,7 +285,8 @@ export function Provider({children}: React.PropsWithChildren) { postLanguages: possibleSourceLanguages, }, })) - } catch (e) { + } catch (err) { + const e = err as Error logger.error('Failed to translate text on device', {safeMessage: e}) // On-device translation failed (language pack missing or user // dismissed the download prompt). @@ -295,6 +301,21 @@ export function Provider({children}: React.PropsWithChildren) { textLength: text.length, }) let errorMessage = l`Device failed to translate :(` + if (e.message === E_SAME_AS_SOURCE_LANGUAGE) { + errorMessage = l`Translation to the same language is unavailable on your device.` + } + if (e.message === E_EMPTY_RESULT) { + errorMessage = l`No translation received from your device.` + } + if ( + expectedSourceLanguage && + e.message.includes(E_INVALID_SOURCE_LANGUAGE) + ) { + errorMessage = l`${codeToLanguageName( + expectedSourceLanguage, + langPrefs.appLanguage, + )} is not supported by your device.` + } if (!IS_ANDROID) { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) } @@ -304,7 +325,7 @@ export function Provider({children}: React.PropsWithChildren) { })) } }, - [ax, googleTranslate, l], + [ax, googleTranslate, l, langPrefs.appLanguage], ) const ctx = useMemo( diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 0a969bad5a..ea308c4249 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -182,6 +182,11 @@ msgstr "" msgid "{0} is not available" msgstr "" +#. placeholder {0}: codeToLanguageName( expectedSourceLanguage, langPrefs.appLanguage, ) +#: src/lib/translation/index.tsx:314 +msgid "{0} is not supported by your device." +msgstr "{0} is not supported by your device." + #. placeholder {0}: formatCount(i18n, JOINED_THIS_WEEK) #: src/screens/StarterPack/StarterPackLandingScreen.tsx:232 msgid "{0} joined this week" @@ -852,8 +857,8 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:76 #: src/view/com/composer/GifAltText.tsx:150 #: src/view/com/composer/GifAltText.tsx:217 -#: src/view/com/composer/photos/Gallery.tsx:194 -#: src/view/com/composer/photos/Gallery.tsx:241 +#: src/view/com/composer/photos/Gallery.tsx:196 +#: src/view/com/composer/photos/Gallery.tsx:243 #: src/view/com/composer/photos/ImageAltTextDialog.tsx:95 #: src/view/com/composer/photos/ImageAltTextDialog.tsx:103 msgid "Add alt text" @@ -905,8 +910,8 @@ msgstr "" msgid "Add media to post" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:522 -#: src/components/moderation/ReportDialog/index.tsx:526 +#: src/components/moderation/ReportDialog/index.tsx:532 +#: src/components/moderation/ReportDialog/index.tsx:536 msgid "Add more details (optional)" msgstr "" @@ -997,7 +1002,7 @@ msgstr "" msgid "Additional details (limit 1000 characters)" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:540 +#: src/components/moderation/ReportDialog/index.tsx:550 msgid "Additional details (limit 300 characters)" msgstr "" @@ -1157,7 +1162,7 @@ msgstr "" #: src/components/images/Gallery.tsx:120 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/view/com/composer/GifAltText.tsx:100 -#: src/view/com/composer/photos/Gallery.tsx:212 +#: src/view/com/composer/photos/Gallery.tsx:214 msgid "ALT" msgstr "" @@ -1176,7 +1181,7 @@ msgid "Alt Text" msgstr "" #: src/view/com/composer/GifAltText.tsx:105 -#: src/view/com/composer/photos/Gallery.tsx:124 +#: src/view/com/composer/photos/Gallery.tsx:125 msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." msgstr "" @@ -1827,20 +1832,20 @@ msgstr "" msgid "Browse custom feeds" msgstr "" -#: src/components/FeedInterstitials.tsx:631 +#: src/components/FeedInterstitials.tsx:627 msgid "Browse more accounts" msgstr "" -#: src/components/FeedInterstitials.tsx:761 +#: src/components/FeedInterstitials.tsx:757 msgid "Browse more feeds on the Explore page" msgstr "" -#: src/components/FeedInterstitials.tsx:742 -#: src/components/FeedInterstitials.tsx:745 +#: src/components/FeedInterstitials.tsx:738 +#: src/components/FeedInterstitials.tsx:741 msgid "Browse more suggestions" msgstr "" -#: src/components/FeedInterstitials.tsx:770 +#: src/components/FeedInterstitials.tsx:766 msgid "Browse more suggestions on the Explore page" msgstr "" @@ -1882,7 +1887,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(item.feed.creator.handle, '@') #. placeholder {0}: sanitizeHandle(labeler.creator.handle, '@') #: src/components/LabelingServiceCard/index.tsx:62 -#: src/components/moderation/ReportDialog/index.tsx:843 +#: src/components/moderation/ReportDialog/index.tsx:853 #: src/screens/Search/components/StarterPackCard.tsx:107 #: src/screens/Search/Explore.tsx:969 msgid "By {0}" @@ -1948,7 +1953,7 @@ msgstr "" #: src/screens/Deactivated.tsx:150 #: src/screens/Profile/Header/EditProfileDialog.tsx:215 #: src/screens/Profile/Header/EditProfileDialog.tsx:223 -#: src/screens/Search/Shell.tsx:396 +#: src/screens/Search/Shell.tsx:397 #: src/screens/Settings/AppIconSettings/index.tsx:42 #: src/screens/Settings/AppIconSettings/index.tsx:228 #: src/screens/Settings/components/ChangeHandleDialog.tsx:80 @@ -1974,7 +1979,7 @@ msgstr "" msgid "Cancel reactivation and sign out" msgstr "" -#: src/screens/Search/Shell.tsx:387 +#: src/screens/Search/Shell.tsx:388 msgid "Cancel search" msgstr "" @@ -2023,7 +2028,7 @@ msgstr "" msgid "Change Handle" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:439 +#: src/components/moderation/ReportDialog/index.tsx:449 msgid "Change moderation service" msgstr "" @@ -2036,11 +2041,11 @@ msgstr "" msgid "Change password dialog" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:304 +#: src/components/moderation/ReportDialog/index.tsx:314 msgid "Change report category" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:384 +#: src/components/moderation/ReportDialog/index.tsx:394 msgid "Change report reason" msgstr "" @@ -2213,7 +2218,7 @@ msgstr "" msgid "Clear image cache" msgstr "" -#: src/components/forms/SearchInput.tsx:69 +#: src/components/forms/SearchInput.tsx:87 msgid "Clear search query" msgstr "" @@ -2333,7 +2338,7 @@ msgstr "" msgid "Close dialog" msgstr "" -#: src/view/shell/index.web.tsx:131 +#: src/view/shell/index.web.tsx:129 msgid "Close drawer menu" msgstr "" @@ -2425,6 +2430,7 @@ msgstr "" msgid "Complete the challenge" msgstr "" +#: src/lib/hotkeys/index.tsx:66 #: src/view/com/feeds/ComposerPrompt.tsx:147 #: src/view/shell/desktop/LeftNav.tsx:575 msgid "Compose new post" @@ -2913,8 +2919,8 @@ msgstr "" #. Accessibility label for button to create a moderation report for the selected option #. placeholder {0}: option.title -#: src/components/moderation/ReportDialog/index.tsx:703 -#: src/components/moderation/ReportDialog/index.tsx:749 +#: src/components/moderation/ReportDialog/index.tsx:713 +#: src/components/moderation/ReportDialog/index.tsx:759 msgid "Create report for {0}" msgstr "" @@ -3159,7 +3165,7 @@ msgstr "" msgid "Developer options" msgstr "" -#: src/lib/translation/index.tsx:297 +#: src/lib/translation/index.tsx:303 msgid "Device failed to translate :(" msgstr "Device failed to translate :(" @@ -3489,7 +3495,7 @@ msgstr "" #: src/view/com/composer/photos/EditImageDialog.web.tsx:86 #: src/view/com/composer/photos/EditImageDialog.web.tsx:90 -#: src/view/com/composer/photos/Gallery.tsx:219 +#: src/view/com/composer/photos/Gallery.tsx:221 msgid "Edit image" msgstr "" @@ -3760,7 +3766,7 @@ msgstr "" msgid "Enter your username and password" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:148 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:150 msgid "Enters full screen" msgstr "" @@ -4201,7 +4207,7 @@ msgstr "" #. placeholder {0}: sanitizeHandle(feed.creatorHandle, '@') #. placeholder {0}: sanitizeHandle(view.creator.handle, '@') #: src/components/FeedCard.tsx:170 -#: src/state/queries/feed.ts:121 +#: src/state/queries/feed.ts:118 #: src/view/com/feeds/FeedSourceCard.tsx:151 msgid "Feed by {0}" msgstr "" @@ -4344,7 +4350,7 @@ msgstr "" msgid "Find people to follow" msgstr "" -#: src/screens/Search/Shell.tsx:529 +#: src/screens/Search/Shell.tsx:530 msgid "Find posts, users, and feeds on Bluesky" msgstr "" @@ -4388,6 +4394,10 @@ msgstr "" msgid "Focus code input" msgstr "" +#: src/lib/hotkeys/index.tsx:73 +msgid "Focus the search field" +msgstr "Focus the search field" + #. User is not following this account, click to follow #: src/components/ProfileCard.tsx:546 #: src/components/ProfileHoverCard/index.web.tsx:495 @@ -5933,7 +5943,7 @@ msgstr "" msgid "Load new notifications" msgstr "" -#: src/screens/Profile/ProfileFeed/index.tsx:203 +#: src/screens/Profile/ProfileFeed/index.tsx:204 #: src/screens/Profile/Sections/Feed.tsx:117 #: src/screens/ProfileList/FeedSection.tsx:113 #: src/view/com/feeds/FeedPage.tsx:170 @@ -6236,7 +6246,7 @@ msgstr "" msgid "Music" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:169 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:171 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:97 msgctxt "video" msgid "Mute" @@ -6383,8 +6393,8 @@ msgstr "" msgid "Navigates to your profile" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:335 -#: src/components/moderation/ReportDialog/index.tsx:352 +#: src/components/moderation/ReportDialog/index.tsx:345 +#: src/components/moderation/ReportDialog/index.tsx:362 msgid "Need to report a copyright violation, legal request, or regulatory compliance issue?" msgstr "" @@ -6470,7 +6480,7 @@ msgstr "" msgid "New password" msgstr "" -#: src/screens/Profile/ProfileFeed/index.tsx:220 +#: src/screens/Profile/ProfileFeed/index.tsx:221 #: src/screens/ProfileList/index.tsx:251 #: src/screens/ProfileList/index.tsx:300 #: src/view/screens/Feeds.tsx:553 @@ -6480,13 +6490,9 @@ msgid "New post" msgstr "" #: src/view/com/feeds/FeedPage.tsx:181 -msgctxt "action" -msgid "New post" -msgstr "" - #: src/view/shell/desktop/LeftNav.tsx:583 msgctxt "action" -msgid "New Post" +msgid "New post" msgstr "" #: src/view/com/notifications/NotificationFeedItem.tsx:545 @@ -6712,6 +6718,10 @@ msgstr "" msgid "No thanks" msgstr "" +#: src/lib/translation/index.tsx:308 +msgid "No translation received from your device." +msgstr "No translation received from your device." + #: src/view/screens/Profile.tsx:500 msgid "No video posts yet" msgstr "" @@ -7262,7 +7272,7 @@ msgstr "" msgid "Password updated!" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:153 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:395 msgid "Pause" msgstr "" @@ -7377,7 +7387,7 @@ msgstr "" msgid "Pinned to your feeds" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:153 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:396 msgid "Play" msgstr "" @@ -7404,7 +7414,7 @@ msgstr "" msgid "Plays or pauses the GIF" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:154 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:156 msgid "Plays or pauses the video" msgstr "" @@ -8121,7 +8131,7 @@ msgstr "" msgid "Remove from your feeds?" msgstr "" -#: src/view/com/composer/photos/Gallery.tsx:228 +#: src/view/com/composer/photos/Gallery.tsx:230 msgid "Remove image" msgstr "" @@ -8329,7 +8339,7 @@ msgid "Report conversation" msgstr "" #: src/components/moderation/ReportDialog/index.tsx:98 -#: src/components/moderation/ReportDialog/index.tsx:255 +#: src/components/moderation/ReportDialog/index.tsx:265 msgid "Report dialog" msgstr "" @@ -8544,7 +8554,7 @@ msgstr "" #: src/components/dms/MessageItem.tsx:322 #: src/components/Error.tsx:66 #: src/components/Lists.tsx:115 -#: src/components/moderation/ReportDialog/index.tsx:289 +#: src/components/moderation/ReportDialog/index.tsx:299 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 #: src/components/StarterPack/ProfileStarterPacks.tsx:377 @@ -8566,7 +8576,7 @@ msgstr "" msgid "Retry" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:286 +#: src/components/moderation/ReportDialog/index.tsx:296 #: src/view/screens/Storybook/Admonitions.tsx:61 msgid "Retry loading report options" msgstr "" @@ -8722,10 +8732,10 @@ msgid "Scroll to top" msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:515 -#: src/components/forms/SearchInput.tsx:33 -#: src/components/forms/SearchInput.tsx:35 +#: src/components/forms/SearchInput.tsx:51 +#: src/components/forms/SearchInput.tsx:53 #: src/screens/Search/Shell.tsx:354 -#: src/screens/Search/Shell.tsx:517 +#: src/screens/Search/Shell.tsx:518 #: src/view/shell/bottom-bar/BottomBar.tsx:199 msgid "Search" msgstr "" @@ -8759,14 +8769,14 @@ msgstr "" msgid "Search for \"{interestsDisplayName}\" (active)" msgstr "" -#: src/view/shell/desktop/Search.tsx:130 -msgid "Search for \"{query}\"" -msgstr "" - #: src/screens/Search/components/AutocompleteResults.tsx:47 msgid "Search for \"{searchText}\"" msgstr "" +#: src/view/shell/desktop/Search.tsx:129 +msgid "Search for “{tQuery}”" +msgstr "Search for “{tQuery}”" + #: src/screens/StarterPack/Wizard/index.tsx:552 msgid "Search for feeds that you want to suggest to others." msgstr "" @@ -8864,12 +8874,12 @@ msgstr "" msgid "See jobs at Bluesky" msgstr "" -#: src/components/FeedInterstitials.tsx:584 -#: src/components/FeedInterstitials.tsx:645 +#: src/components/FeedInterstitials.tsx:583 +#: src/components/FeedInterstitials.tsx:641 msgid "See more" msgstr "" -#: src/components/FeedInterstitials.tsx:565 +#: src/components/FeedInterstitials.tsx:564 msgid "See more suggested profiles" msgstr "" @@ -8906,7 +8916,7 @@ msgstr "" msgid "Select a color" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:374 +#: src/components/moderation/ReportDialog/index.tsx:384 msgid "Select a reason" msgstr "" @@ -8991,7 +9001,7 @@ msgstr "" msgid "Select languages" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:424 +#: src/components/moderation/ReportDialog/index.tsx:434 msgid "Select moderation service" msgstr "" @@ -9090,7 +9100,7 @@ msgstr "" msgid "Send post to..." msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:814 +#: src/components/moderation/ReportDialog/index.tsx:824 msgid "Send report to {title}" msgstr "" @@ -9565,7 +9575,7 @@ msgstr "" msgid "Some of your verifications are invalid." msgstr "" -#: src/components/FeedInterstitials.tsx:724 +#: src/components/FeedInterstitials.tsx:720 msgid "Some other feeds you might like" msgstr "" @@ -9593,7 +9603,7 @@ msgid "Something went wrong" msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 -#: src/components/moderation/ReportDialog/index.tsx:281 +#: src/components/moderation/ReportDialog/index.tsx:291 #: src/screens/Deactivated.tsx:86 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 @@ -9625,7 +9635,7 @@ msgstr "" msgid "Sorry, we're unable to load account suggestions at this time." msgstr "" -#: src/App.native.tsx:142 +#: src/App.native.tsx:141 #: src/App.web.tsx:119 msgid "Sorry! Your session expired. Please sign in again." msgstr "" @@ -9778,9 +9788,9 @@ msgstr "" msgid "Submit Appeal" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:502 -#: src/components/moderation/ReportDialog/index.tsx:563 -#: src/components/moderation/ReportDialog/index.tsx:570 +#: src/components/moderation/ReportDialog/index.tsx:512 +#: src/components/moderation/ReportDialog/index.tsx:573 +#: src/components/moderation/ReportDialog/index.tsx:580 msgid "Submit report" msgstr "" @@ -10175,7 +10185,7 @@ msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" #: src/screens/Search/Explore.tsx:1025 -#: src/view/com/posts/PostFeed.tsx:763 +#: src/view/com/posts/PostFeed.tsx:773 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -10609,7 +10619,7 @@ msgstr "" msgid "Toggle to enable or disable adult content" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:171 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:173 msgid "Toggles the sound" msgstr "" @@ -10664,6 +10674,10 @@ msgstr "Translating" msgid "Translating…" msgstr "Translating…" +#: src/lib/translation/index.tsx:305 +msgid "Translation to the same language is unavailable on your device." +msgstr "Translation to the same language is unavailable on your device." + #: src/screens/Settings/ThreadPreferences.tsx:87 #: src/screens/Settings/ThreadPreferences.tsx:92 msgid "Tree view" @@ -10843,7 +10857,7 @@ msgstr "" msgid "Unfollows the user" msgstr "" -#: src/components/moderation/ReportDialog/index.tsx:486 +#: src/components/moderation/ReportDialog/index.tsx:496 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -10882,7 +10896,7 @@ msgstr "" msgid "Unmute" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:168 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:170 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:96 msgctxt "video" msgid "Unmute" @@ -10981,6 +10995,10 @@ msgstr "" msgid "Unsubscribed from list" msgstr "" +#: src/view/com/composer/text-input/TextInput.tsx:131 +msgid "Unsupported clipboard content" +msgstr "Unsupported clipboard content" + #: src/view/com/composer/Composer.tsx:1369 msgid "Unsupported video type: {mimeType}" msgstr "" @@ -11136,7 +11154,7 @@ msgid "User list by {0}" msgstr "" #. placeholder {0}: sanitizeHandle(view.creator.handle, '@') -#: src/state/queries/feed.ts:162 +#: src/state/queries/feed.ts:159 msgid "User List by {0}" msgstr "" @@ -11295,8 +11313,8 @@ msgstr "" msgid "Version {0}" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:86 -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:147 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:87 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:149 msgid "Video" msgstr "" @@ -11344,7 +11362,7 @@ msgid "Video uploaded" msgstr "" #. placeholder {0}: embed.alt -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:86 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:87 msgid "Video: {0}" msgstr "" @@ -12522,7 +12540,7 @@ msgid "Your reply was sent" msgstr "" #. placeholder {0}: state.selectedLabeler?.creator.displayName -#: src/components/moderation/ReportDialog/index.tsx:513 +#: src/components/moderation/ReportDialog/index.tsx:523 msgid "Your report will be sent to <0>{0}." msgstr "" diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index c3c9405287..64d6529728 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -52,8 +52,9 @@ import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import {useAnalytics} from '#/analytics' +import {IS_NATIVE} from '#/env' -const PARENT_CHUNK_SIZE = 20 +const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20 const CHILDREN_CHUNK_SIZE = 50 export function PostThread({uri}: {uri: string}) { diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index 03fae0207a..634779f9db 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -185,6 +185,7 @@ export function ProfileFeedScreenInner({ {showAutocomplete && ( diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 0afa272c53..f01a7a081d 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -5,7 +5,7 @@ import { type AppBskyFeedDefs, } from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery' diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 34422dc0c0..b8a6c58c35 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -1,7 +1,7 @@ import {useEffect, useMemo, useState} from 'react' import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions' diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 93170f6275..f9711bfa3a 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react' +import {useHotkeysContext} from '#/lib/hotkeys' import {type DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' import {IS_WEB} from '#/env' @@ -62,6 +63,7 @@ export function useDialogFullyExpandedCountContext() { export function Provider({children}: React.PropsWithChildren<{}>) { const [fullyExpandedCount, setFullyExpandedCount] = useState(0) + const {disableScope, enableScope} = useHotkeysContext() const activeDialogs = useRef< Map> @@ -77,18 +79,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return openDialogs.current.size > 0 } else { - BottomSheetNativeComponent.dismissAll() + void BottomSheetNativeComponent.dismissAll() return false } }, []) - const setDialogIsOpen = useCallback((id: string, isOpen: boolean) => { - if (isOpen) { - openDialogs.current.add(id) - } else { - openDialogs.current.delete(id) - } - }, []) + const setDialogIsOpen = useCallback( + (id: string, isOpen: boolean) => { + if (isOpen) { + openDialogs.current.add(id) + } else { + openDialogs.current.delete(id) + } + if (openDialogs.current.size > 0) { + disableScope('global') + } else { + enableScope('global') + } + }, + [disableScope, enableScope], + ) const context = useMemo( () => ({ diff --git a/src/state/events.ts b/src/state/events.ts index dcd36464ec..87a1fab705 100644 --- a/src/state/events.ts +++ b/src/state/events.ts @@ -1,4 +1,4 @@ -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' type UnlistenFn = () => void @@ -45,3 +45,11 @@ export function listenPostCreated(fn: () => void): UnlistenFn { emitter.on('post-created', fn) return () => emitter.off('post-created', fn) } + +export function emitFocusSearch() { + emitter.emit('focus-search') +} +export function listenFocusSearch(fn: () => void): UnlistenFn { + emitter.on('focus-search', fn) + return () => emitter.off('focus-search', fn) +} diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx index 2f0d652210..4d3e9795dc 100644 --- a/src/state/global-gesture-events/index.tsx +++ b/src/state/global-gesture-events/index.tsx @@ -7,7 +7,7 @@ import { type GestureUpdateEvent, type PanGestureHandlerEventPayload, } from 'react-native-gesture-handler' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' export type GlobalGestureEvents = { begin: GestureStateChangeEvent diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 52c74278cb..1e22cc98a4 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,7 +1,8 @@ -import {createContext, useContext, useMemo, useState} from 'react' +import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useHotkeysContext} from '#/lib/hotkeys' import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types' export type Lightbox = { @@ -28,6 +29,15 @@ LightboxControlContext.displayName = 'LightboxControlContext' export function Provider({children}: React.PropsWithChildren<{}>) { const [activeLightbox, setActiveLightbox] = useState(null) + const {disableScope, enableScope} = useHotkeysContext() + + useEffect(() => { + if (activeLightbox) { + disableScope('global') + } else { + enableScope('global') + } + }, [activeLightbox, disableScope, enableScope]) const openLightbox = useNonReactiveCallback( (lightbox: Omit) => { diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index d29049c872..b6c8ee2f16 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -5,8 +5,8 @@ import { type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, } from '@atproto/api' -import {XRPCError} from '@atproto/xrpc' -import EventEmitter from 'eventemitter3' +import {XRPCError} from '@atproto/api' +import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' import {networkRetry} from '#/lib/async/retry' diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts index e8404fd000..ce9518212b 100644 --- a/src/state/messages/events/agent.ts +++ b/src/state/messages/events/agent.ts @@ -1,5 +1,5 @@ import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' import {networkRetry} from '#/lib/async/retry' diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 484890ba15..3c545362b8 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,7 @@ -import {createContext, useContext, useMemo, useState} from 'react' +import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useHotkeysContext} from '#/lib/hotkeys' export interface UserAddRemoveListsModal { name: 'user-add-remove-lists' @@ -47,6 +48,15 @@ ModalControlContext.displayName = 'ModalControlContext' export function Provider({children}: React.PropsWithChildren<{}>) { const [activeModals, setActiveModals] = useState([]) + const {disableScope, enableScope} = useHotkeysContext() + + useEffect(() => { + if (activeModals.length > 0) { + disableScope('global') + } else { + enableScope('global') + } + }, [activeModals.length, disableScope, enableScope]) const openModal = useNonReactiveCallback((modal: Modal) => { setActiveModals(modals => [...modals, modal]) diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index ff278b74be..35e796810d 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -1,4 +1,4 @@ -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index c2b173b5ce..b85d917ad3 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -22,13 +22,10 @@ import { import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' -import { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import {RQKEY as listQueryKey} from '#/state/queries/list' import {usePreferencesQuery} from '#/state/queries/preferences' +import {createQueryKey} from '#/state/queries/util' import {useAgent, useSession} from '#/state/session' import {router} from '#/routes' import {useModerationOpts} from '../preferences/moderation-opts' @@ -416,10 +413,20 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = { contentMode: undefined, } -const createPinnedFeedInfosQueryKeyRoot = ( +const createPinnedFeedInfosQueryKey = ( kind: 'pinned' | 'saved', feedUris: string[], -) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris] +) => + createQueryKey( + 'feed-info', + { + kind, + feedUris, + }, + { + persistedVersion: 1, + }, + ) export function usePinnedFeedsInfos() { const {hasSession} = useSession() @@ -428,11 +435,11 @@ export function usePinnedFeedsInfos() { const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? [] return useQuery({ - queryKey: createPinnedFeedInfosQueryKeyRoot( + queryKey: createPinnedFeedInfosQueryKey( 'pinned', pinnedItems.map(f => f.value), ), - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, staleTime: STALE.INFINITY, enabled: !isLoadingPrefs, queryFn: async () => { @@ -536,11 +543,11 @@ export function useSavedFeeds() { const queryClient = useQueryClient() return useQuery({ - queryKey: createPinnedFeedInfosQueryKeyRoot( + queryKey: createPinnedFeedInfosQueryKey( 'saved', savedItems.map(f => f.value), ), - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, staleTime: STALE.INFINITY, enabled: !isLoadingPrefs, placeholderData: previousData => { diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index bac6931fd5..183d8c883a 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -19,22 +19,6 @@ export const STALE = { INFINITY: Infinity, } -/** - * Root key for persisted queries. - * - * If the `querykey` of your query uses this at index 0, it will be - * persisted automatically by the `PersistQueryClientProvider` in - * `#/lib/react-query.tsx`. - * - * Be careful when using this, since it will change the query key and may - * break any cases where we call `invalidateQueries` or `refetchQueries` - * with the old key. - * - * Also, only use this for queries that are safe to persist between - * app launches (like user preferences). - * - * Note that for queries that are persisted, it is recommended to extend - * the `gcTime` to a longer duration, otherwise it'll get busted - */ -export const PERSISTED_QUERY_ROOT = 'PERSISTED' -export const PERSISTED_QUERY_GCTIME = Infinity +export const GCTIME = { + INFINITY: Infinity, +} diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts index 9bf4372739..e7251866fe 100644 --- a/src/state/queries/labeler.ts +++ b/src/state/queries/labeler.ts @@ -3,15 +3,12 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {z} from 'zod' import {MAX_LABELERS} from '#/lib/constants' -import { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import { preferencesQueryKey, usePreferencesQuery, } from '#/state/queries/preferences' +import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' const labelerInfoQueryKeyRoot = 'labeler-info' @@ -26,11 +23,8 @@ export const labelersInfoQueryKey = (dids: string[]) => [ dids.slice().sort(), ] -const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [ - PERSISTED_QUERY_ROOT, - 'labelers-detailed-info', - dids, -] +const createLabelersDetailedInfoQueryKey = (dids: string[]) => + createQueryKey('labelers-detailed-info', {dids}, {persistedVersion: 1}) export function useLabelerInfoQuery({ did, @@ -69,8 +63,8 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { const agent = useAgent() return useQuery({ enabled: !!dids.length, - queryKey: persistedLabelersDetailedInfoQueryKey(dids), - gcTime: PERSISTED_QUERY_GCTIME, + queryKey: createLabelersDetailedInfoQueryKey(dids), + gcTime: GCTIME.INFINITY, staleTime: STALE.MINUTES.ONE, queryFn: async () => { const res = await agent.app.bsky.labeler.getServices({ diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index ce6e209386..bf7505f91b 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -12,7 +12,7 @@ import { } from 'react' import {AppState} from 'react-native' import {useQueryClient} from '@tanstack/react-query' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' import {resetBadgeCount} from '#/lib/notifications/notifications' diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index e78a5d73f1..b57e9ffe85 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -9,11 +9,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' -import { - PERSISTED_QUERY_GCTIME, - PERSISTED_QUERY_ROOT, - STALE, -} from '#/state/queries' +import {GCTIME, STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, @@ -23,6 +19,7 @@ import { type ThreadViewPreferences, type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' +import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' import {useAgeAssurance} from '#/ageAssurance' @@ -33,7 +30,11 @@ export * from '#/state/queries/preferences/const' export * from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/types' -export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences'] +export const preferencesQueryKey = createQueryKey( + 'getPreferences', + {}, + {persistedVersion: 1}, +) export function usePreferencesQuery() { const agent = useAgent() @@ -44,7 +45,7 @@ export function usePreferencesQuery() { structuralSharing: replaceEqualDeep, refetchOnWindowFocus: true, queryKey: preferencesQueryKey, - gcTime: PERSISTED_QUERY_GCTIME, + gcTime: GCTIME.INFINITY, queryFn: async () => { if (!agent.did) { return DEFAULT_LOGGED_OUT_PREFERENCES diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index b792fecb46..7db15d0bc3 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -99,6 +99,7 @@ export function useProfilesQuery({ }) { const agent = useAgent() return useQuery({ + enabled: handles.length > 0, staleTime: STALE.MINUTES.FIVE, queryKey: profilesQueryKey(handles), queryFn: async () => { diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index b40e380724..a6ca192be3 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,9 +1,5 @@ -import {AtUri} from '@atproto/api' -import { - type QueryClient, - useQuery, - type UseQueryResult, -} from '@tanstack/react-query' +import {AtUri, type BskyAgent} from '@atproto/api' +import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -12,26 +8,12 @@ import {useUnstableProfileViewCache} from './profile' const RQKEY_ROOT = 'resolved-did' export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle] -type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error> -export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult { - const urip = new AtUri(uri || '') - const res = useResolveDidQuery(urip.host) - if (res.data) { - // @ts-expect-error TODO new-sdk-migration - urip.host = res.data - return { - ...res, - data: {did: urip.host, uri: urip.toString()}, - } as UriUseQueryResult - } - return res as UriUseQueryResult -} - -export function useResolveDidQuery(didOrHandle: string | undefined) { - const agent = useAgent() - const {getUnstableProfile} = useUnstableProfileViewCache() - - return useQuery({ +const resolvedDidQueryOptions = ( + agent: BskyAgent, + getUnstableProfile: (did: string) => {did: string} | undefined, + didOrHandle: string | undefined, +) => + queryOptions({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY(didOrHandle ?? ''), queryFn: async () => { @@ -50,6 +32,30 @@ export function useResolveDidQuery(didOrHandle: string | undefined) { }, enabled: !!didOrHandle, }) + +export function useResolveUriQuery(uri: string | undefined) { + const urip = new AtUri(uri || '') + const host = urip.host + + const agent = useAgent() + const {getUnstableProfile} = useUnstableProfileViewCache() + + return useQuery({ + ...resolvedDidQueryOptions(agent, getUnstableProfile, host), + select: did => ({ + did, + uri: AtUri.make(did, urip.collection, urip.rkey).toString(), + }), + }) +} + +export function useResolveDidQuery(didOrHandle: string | undefined) { + const agent = useAgent() + const {getUnstableProfile} = useUnstableProfileViewCache() + + return useQuery( + resolvedDidQueryOptions(agent, getUnstableProfile, didOrHandle), + ) } export function precacheResolvedUri( diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index 363b6b6e02..7ea54745c0 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -14,6 +14,61 @@ import { import * as bsky from '#/types/bsky' +export type StructuredQueryKey> = readonly [ + string, + T, + { + persistedVersion?: number + }, +] + +/** + * Helper method to ensure consistent query keys and key ordering + */ +export function createQueryKey>( + /** + * The query key root. All queries must have a root. + */ + root: string, + /** + * Any arguments the query depends on, and if changed, should result in the query being refetched. + */ + args: T, + options: { + /** + * If provided, this indicates that the query is persisted and the version + * of the persisted query format. + * + * This is used to ensure that when we make breaking changes to the + * persisted query format, we can increment the version and avoid trying to + * read old persisted queries with the new format. + * + * If you're persisting your queries, you probably want to set `gcTime: + * GCTIME.INFINITY` for this query, otherwise it'll get busted immediately + * after being persisted. + */ + persistedVersion?: number + } = {}, +): StructuredQueryKey { + return [root, args, options] as const +} + +export function isQueryPersisted( + queryKey: QueryKey, +): queryKey is StructuredQueryKey> { + return ( + Array.isArray(queryKey) && + queryKey.length === 3 && + typeof queryKey[0] === 'string' && + typeof queryKey[1] === 'object' && + queryKey[1] !== null && + typeof queryKey[2] === 'object' && + queryKey[2] !== null && + 'persistedVersion' in queryKey[2] && + typeof queryKey[2].persistedVersion === 'number' + ) +} + export async function truncateAndInvalidate( queryClient: QueryClient, queryKey: QueryKey, diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx deleted file mode 100644 index a1e76fdfd9..0000000000 --- a/src/state/shell/composer/useComposerKeyboardShortcut.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import {useEffect} from 'react' - -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {useDialogStateContext} from '#/state/dialogs' -import {useLightbox} from '#/state/lightbox' -import {useModals} from '#/state/modals' -import {useSession} from '#/state/session' -import {useIsDrawerOpen} from '#/state/shell/drawer-open' - -/** - * Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2} - */ -function shouldIgnore(event: KeyboardEvent) { - const target: any = event.target || event.srcElement - if (!target) return false - const {tagName} = target - if (!tagName) return false - const isInput = - tagName === 'INPUT' && - ![ - 'checkbox', - 'radio', - 'range', - 'button', - 'file', - 'reset', - 'submit', - 'color', - ].includes(target.type) - // ignore: isContentEditable === 'true', and