diff --git a/.env.example b/.env.example index 5cf8e07b1c..6ab02256e4 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ SENTRY_AUTH_TOKEN= EXPO_PUBLIC_ENV=development EXPO_PUBLIC_LOG_LEVEL=debug EXPO_PUBLIC_LOG_DEBUG= +EXPO_PUBLIC_BUNDLE_IDENTIFIER= +EXPO_PUBLIC_BUNDLE_DATE=0 diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 3f60705792..c445ca2d52 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -1,9 +1,9 @@ name: build-and-push-bskyweb-aws on: + workflow_dispatch: push: branches: - main - - 3p-moderators env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} @@ -54,3 +54,6 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + env: + EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD) + EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H") diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 9a26b82c21..1a52694ac1 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -57,6 +57,8 @@ jobs: run: | export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env echo "$json" > google-services.json - name: 🏗️ EAS Build diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index c9752d8621..2350e46ad9 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -65,6 +65,8 @@ jobs: - name: ✏️ Write environment variables run: | echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json - name: 🏗️ EAS Build diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 2e07335eef..0f3a632134 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -144,6 +144,8 @@ jobs: run: | export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env echo "$json" > google-services.json - name: 🏗️ Create Bundle @@ -222,6 +224,8 @@ jobs: - name: ✏️ Write environment variables run: | echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json - name: 🏗️ EAS Build @@ -283,6 +287,8 @@ jobs: run: | export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' echo "${{ secrets.ENV_TOKEN }}" > .env + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env echo "$json" > google-services.json - name: 🏗️ EAS Build diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml index 2576c34794..a87e7f1443 100644 --- a/.github/workflows/golang-test-lint.yml +++ b/.github/workflows/golang-test-lint.yml @@ -20,8 +20,8 @@ jobs: uses: actions/setup-go@v3 with: go-version: '1.21' - - name: Dummy JS File - run: touch bskyweb/static/js/blah.js + - name: Dummy Static Files + run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt - name: Check run: cd bskyweb/ && make check - name: Build (binary) @@ -37,7 +37,7 @@ jobs: uses: actions/setup-go@v3 with: go-version: '1.21' - - name: Dummy JS File - run: touch bskyweb/static/js/blah.js + - name: Dummy Static Files + run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt - name: Lint run: cd bskyweb/ && make lint diff --git a/Dockerfile.embedr b/Dockerfile.embedr index c70251658b..63f0609809 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -40,6 +40,7 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs # hack around issue with empty directory and go:embed RUN touch bskyweb/static/js/empty.txt +RUN touch bskyweb/static/media/empty.txt # # Generate the embedr Go binary. diff --git a/app.config.js b/app.config.js index 528c8436f8..b1fe336679 100644 --- a/app.config.js +++ b/app.config.js @@ -91,6 +91,28 @@ module.exports = function (config) { entitlements: { 'com.apple.security.application-groups': 'group.app.bsky', }, + privacyManifests: { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: + 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1', '3B52.1', '0A2A.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace', + NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryBootTime', + NSPrivacyAccessedAPITypeReasons: ['35F9.1'], + }, + { + NSPrivacyAccessedAPIType: + 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + }, }, androidStatusBar: { barStyle: 'light-content', diff --git a/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg b/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..6d661a55e5 --- /dev/null +++ b/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/paperPlane_stroke2_corner0_rounded.svg b/assets/icons/paperPlane_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..628085cb5a --- /dev/null +++ b/assets/icons/paperPlane_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/plusSmall_stroke2_corner0_rounded.svg b/assets/icons/plusSmall_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..957dd14488 --- /dev/null +++ b/assets/icons/plusSmall_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/kawaii.png b/assets/kawaii.png new file mode 100644 index 0000000000..79cab8e525 Binary files /dev/null and b/assets/kawaii.png differ diff --git a/assets/kawaii_smol.png b/assets/kawaii_smol.png new file mode 100644 index 0000000000..4bed56208e Binary files /dev/null and b/assets/kawaii_smol.png differ diff --git a/bskyweb/.gitignore b/bskyweb/.gitignore index fad122a280..05b3ad7ab5 100644 --- a/bskyweb/.gitignore +++ b/bskyweb/.gitignore @@ -10,6 +10,8 @@ static/js/*.js static/js/*.map static/js/*.js.LICENSE.txt static/js/empty.txt +static/media/*.png +static/media/empty.txt templates/scripts.html templates/*-embed.html static/embed/*.html diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 54580d6431..e1b009646a 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error { // Cache javascript and images files for 1 week, which works because // they're always versioned (e.g. /static/js/main.64c14927.js) - if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") { + if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") { maxAge = 7 * (60 * 60 * 24) // 1 week } diff --git a/bskyweb/static/media/.gitkeep b/bskyweb/static/media/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package.json b/package.json index 0cb6bdb282..39b90b3c32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.80.0", + "version": "1.81.0", "private": true, "engines": { "node": ">=18" @@ -15,7 +15,7 @@ "web": "expo start --web", "use-build-number": "./scripts/useBuildNumberEnv.sh", "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", - "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/", + "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/", "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", "build-ios": "yarn use-build-number-with-bump eas build -p ios", "build-android": "yarn use-build-number-with-bump eas build -p android", @@ -50,6 +50,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { + "@atproto-labs/api": "^0.12.8-clipclops.0", "@atproto/api": "^0.12.5", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", @@ -108,7 +109,7 @@ "email-validator": "^2.0.4", "emoji-mart": "^5.5.2", "eventemitter3": "^5.0.1", - "expo": "^50.0.8", + "expo": "^50.0.17", "expo-application": "^5.8.3", "expo-build-properties": "^0.11.1", "expo-camera": "~14.0.4", @@ -168,6 +169,7 @@ "react-native-get-random-values": "~1.11.0", "react-native-image-crop-picker": "^0.38.1", "react-native-ios-context-menu": "^1.15.3", + "react-native-keyboard-controller": "^1.11.7", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^8.1.0", "react-native-progress": "bluesky-social/react-native-progress", @@ -183,6 +185,7 @@ "react-native-web-webview": "^1.0.2", "react-native-webview": "13.6.4", "react-responsive": "^9.0.2", + "react-textarea-autosize": "^8.5.3", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.1", "statsig-react-native-expo": "^4.6.1", diff --git a/src/App.native.tsx b/src/App.native.tsx index cf96781b73..9fa82e9cdb 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -15,9 +15,11 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {init as initPersistedState} from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' -import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount' +import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' +import {readLastActiveAccount} from '#/state/session/util' import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {useNotificationsListener} from 'lib/notifications/notifications' import {QueryProvider} from 'lib/react-query' @@ -32,6 +34,7 @@ import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import { Provider as SessionProvider, + SessionAccount, useSession, useSessionApi, } from 'state/session' @@ -51,8 +54,9 @@ import {listenSessionDropped} from './state/events' SplashScreen.preventAutoHideAsync() function InnerApp() { - const {isInitialLoad, currentAccount} = useSession() - const {resumeSession} = useSessionApi() + const [isReady, setIsReady] = React.useState(false) + const {currentAccount} = useSession() + const {initSession} = useSessionApi() const theme = useColorModeTheme() const {_} = useLingui() @@ -60,46 +64,61 @@ function InnerApp() { // init useEffect(() => { - listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) - }) - + async function resumeSession(account?: SessionAccount) { + try { + if (account) { + await initSession(account) + } + } catch (e) { + logger.error(`session: resumeSession failed`, {message: e}) + } finally { + setIsReady(true) + } + } const account = readLastActiveAccount() resumeSession(account) - }, [resumeSession, _]) + }, [initSession]) + + useEffect(() => { + return listenSessionDropped(() => { + Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) + }) + }, [_]) return ( - - - - - - - - - - - {/* All components should be within this provider */} - - - - - - - - - - - - - - - - + + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + ) diff --git a/src/App.web.tsx b/src/App.web.tsx index 639fbfafc5..0fed089cbb 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -4,11 +4,15 @@ import 'view/icons' import React, {useEffect, useState} from 'react' import {RootSiblingParent} from 'react-native-root-siblings' import {SafeAreaProvider} from 'react-native-safe-area-context' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {init as initPersistedState} from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' -import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount' +import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' +import {readLastActiveAccount} from '#/state/session/util' import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {QueryProvider} from 'lib/react-query' import {ThemeProvider} from 'lib/ThemeContext' @@ -21,61 +25,85 @@ import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import { Provider as SessionProvider, + SessionAccount, useSession, useSessionApi, } from 'state/session' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import * as Toast from 'view/com/util/Toast' import {ToastContainer} from 'view/com/util/Toast.web' import {Shell} from 'view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' import I18nProvider from './locale/i18nProvider' +import {listenSessionDropped} from './state/events' function InnerApp() { - const {isInitialLoad, currentAccount} = useSession() - const {resumeSession} = useSessionApi() + const [isReady, setIsReady] = React.useState(false) + const {currentAccount} = useSession() + const {initSession} = useSessionApi() const theme = useColorModeTheme() + const {_} = useLingui() useIntentHandler() // init useEffect(() => { + async function resumeSession(account?: SessionAccount) { + try { + if (account) { + await initSession(account) + } + } catch (e) { + logger.error(`session: resumeSession failed`, {message: e}) + } finally { + setIsReady(true) + } + } const account = readLastActiveAccount() resumeSession(account) - }, [resumeSession]) + }, [initSession]) + + useEffect(() => { + return listenSessionDropped(() => { + Toast.show(_(msg`Sorry! Your session expired. Please log in again.`)) + }) + }, [_]) // wait for session to resume - if (isInitialLoad) return null + if (!isReady) return null return ( - - - - - - - - - {/* All components should be within this provider */} - - - - - - - - - - - - - - + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 316a975388..c7ad40ed84 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -48,7 +48,7 @@ import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' import {router} from './routes' import {MessagesConversationScreen} from './screens/Messages/Conversation' -import {MessagesListScreen} from './screens/Messages/List' +import {MessagesScreen} from './screens/Messages/List' import {MessagesSettingsScreen} from './screens/Messages/Settings' import {useModalControls} from './state/modals' import {useUnreadNotifications} from './state/queries/notifications/unread' @@ -462,8 +462,8 @@ function MessagesTabNavigator() { contentStyle: pal.view, }}> MessagesListScreen} + name="Messages" + getComponent={() => MessagesScreen} options={{requireAuth: true}} /> {commonScreens(MessagesTab as typeof HomeTab)} @@ -512,8 +512,8 @@ const FlatNavigator = () => { options={{title: title(msg`Notifications`), requireAuth: true}} /> MessagesListScreen} + name="Messages" + getComponent={() => MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} /> {commonScreens(Flat as typeof HomeTab, numUnread)} @@ -570,7 +570,7 @@ const LINKING = { return buildStateObject('HomeTab', 'Home', params) } if (name === 'Messages') { - return buildStateObject('MessagesTab', 'MessagesList', params) + return buildStateObject('MessagesTab', 'Messages', params) } // if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work return buildStateObject('HomeTab', name, params, [ diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 169e7b84fe..7d696801ed 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -16,12 +16,14 @@ export function AccountList({ onSelectAccount, onSelectOther, otherLabel, + pendingDid, }: { onSelectAccount: (account: SessionAccount) => void onSelectOther: () => void otherLabel?: string + pendingDid: string | null }) { - const {isSwitchingAccounts, currentAccount, accounts} = useSession() + const {currentAccount, accounts} = useSession() const t = useTheme() const {_} = useLingui() @@ -31,6 +33,7 @@ export function AccountList({ return ( @@ -50,7 +54,7 @@ export function AccountList({ )} - + {!hideBackButton && ( + + )} ) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index b5419697be..721e877be6 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({ emptyType = 'page', onRetry, onGoBack, + hideBackButton, sideBorders, }: { isLoading: boolean @@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({ emptyType?: 'page' | 'results' onRetry?: () => Promise onGoBack?: () => void + hideBackButton?: boolean sideBorders?: boolean }): React.ReactNode => { const t = useTheme() @@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({ onRetry={onRetry} onGoBack={onGoBack} sideBorders={sideBorders} + hideBackButton={hideBackButton} /> ) } @@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({ } onRetry={onRetry} onGoBack={onGoBack} + hideBackButton={hideBackButton} sideBorders={sideBorders} /> ) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 051e95b956..3be69b3486 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,27 +1,29 @@ import React from 'react' -import {View, Pressable, ViewStyle, StyleProp} from 'react-native' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import flattenReactChildren from 'react-keyed-flatten-children' +import {isNative} from 'platform/detection' import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Text} from '#/components/Typography' - import {Context} from '#/components/Menu/context' import { ContextType, - TriggerProps, - ItemProps, GroupProps, - ItemTextProps, ItemIconProps, + ItemProps, + ItemTextProps, + TriggerProps, } from '#/components/Menu/types' -import {Button, ButtonText} from '#/components/Button' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {isNative} from 'platform/detection' +import {Text} from '#/components/Typography' -export {useDialogControl as useMenuControl} from '#/components/Dialog' +export { + type DialogControlProps as MenuControlProps, + useDialogControl as useMenuControl, +} from '#/components/Dialog' export function useMemoControlContext() { return React.useContext(Context) diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index d696f986b4..03b397b2b8 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -34,10 +34,17 @@ export function createPortalGroup() { setOutlet(<>{Object.values(map.current)}) }, []) + const contextValue = React.useMemo( + () => ({ + outlet, + append, + remove, + }), + [outlet, append, remove], + ) + return ( - - {props.children} - + {props.children} ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index d1b00d522f..a22436879b 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -9,7 +9,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {pluralize} from '#/lib/strings/helpers' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useProfileShadow} from 'state/cache/profile-shadow' diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 0a171674de..92e848e8eb 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -43,7 +43,9 @@ export function Outer({ + style={[ + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, + ]}> {children} @@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) { ) } -export function DescriptionText({children}: React.PropsWithChildren<{}>) { +export function DescriptionText({ + children, + selectable, +}: React.PropsWithChildren<{selectable?: boolean}>) { const t = useTheme() const {descriptionId} = React.useContext(Context) return ( {children} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 024188ec44..57389ba2b7 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo, useRef, useState} from 'react' -import {Keyboard, TextInput, View} from 'react-native' +import {TextInput, View} from 'react-native' import {Image} from 'expo-image' import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' @@ -216,7 +216,7 @@ function GifList({ keyExtractor={(item: Gif) => item.id} // @ts-expect-error web only style={isWeb && {minHeight: '100vh'}} - onScrollBeginDrag={() => Keyboard.dismiss()} + keyboardDismissMode="on-drag" ListFooterComponent={ hasData ? ( - + ) } -function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { +function MutedWordsInner() { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index 55628a790d..0bd4bcb8cb 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -18,7 +18,7 @@ export function SwitchAccountDialog({ }) { const {_} = useLingui() const {currentAccount} = useSession() - const {onPressSwitchAccount} = useAccountSwitcher() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const onSelectAccount = useCallback( @@ -54,6 +54,7 @@ export function SwitchAccountDialog({ onSelectAccount={onSelectAccount} onSelectOther={onPressAddAccount} otherLabel={_(msg`Add account`)} + pendingDid={pendingDid} /> diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx new file mode 100644 index 0000000000..19a3e0424b --- /dev/null +++ b/src/components/dms/ActionsWrapper.tsx @@ -0,0 +1,83 @@ +import React, {useCallback} from 'react' +import {Keyboard, Pressable, View} from 'react-native' +import Animated, { + cancelAnimation, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {useHaptics} from 'lib/haptics' +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable) + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const playHaptic = useHaptics() + const menuControl = useMenuControl() + + const scale = useSharedValue(1) + const animationDidComplete = useSharedValue(false) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })) + + // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this + // function + const open = useCallback(() => { + Keyboard.dismiss() + menuControl.open() + }, [menuControl]) + + const shrink = useCallback(() => { + 'worklet' + cancelAnimation(scale) + scale.value = withTiming(1, {duration: 200}, () => { + animationDidComplete.value = false + }) + }, [animationDidComplete, scale]) + + const grow = React.useCallback(() => { + 'worklet' + scale.value = withTiming(1.05, {duration: 750}, finished => { + if (!finished) return + animationDidComplete.value = true + runOnJS(playHaptic)() + runOnJS(open)() + + shrink() + }) + }, [scale, animationDidComplete, playHaptic, shrink, open]) + + return ( + + + {children} + + + + ) +} diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx new file mode 100644 index 0000000000..f4c85ab944 --- /dev/null +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const menuControl = useMenuControl() + const viewRef = React.useRef(null) + + const [showActions, setShowActions] = React.useState(false) + + const onMouseEnter = React.useCallback(() => { + setShowActions(true) + }, []) + + const onMouseLeave = React.useCallback(() => { + setShowActions(false) + }, []) + + // We need to handle the `onFocus` separately because we want to know if there is a related target (the element + // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed. + const onFocus = React.useCallback(e => { + if (e.nativeEvent.relatedTarget == null) return + setShowActions(true) + }, []) + + return ( + + {isFromSelf && ( + + + + )} + + {children} + + {!isFromSelf && ( + + + + )} + + ) +} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx new file mode 100644 index 0000000000..16306bb57e --- /dev/null +++ b/src/components/dms/ConvoMenu.tsx @@ -0,0 +1,184 @@ +import React, {useCallback} from 'react' +import {Keyboard, Pressable} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + useMuteConvo, + useUnmuteConvo, +} from '#/state/queries/messages/mute-conversation' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' + +let ConvoMenu = ({ + convo, + profile, + onUpdateConvo, + control, + hideTrigger, + currentScreen, +}: { + convo: ChatBskyConvoDefs.ConvoView + profile: AppBskyActorDefs.ProfileViewBasic + onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void + control?: Menu.MenuControlProps + hideTrigger?: boolean + currentScreen: 'list' | 'conversation' +}): React.ReactNode => { + const navigation = useNavigation() + const {_} = useLingui() + const t = useTheme() + const leaveConvoControl = Prompt.usePromptControl() + + const onNavigateToProfile = useCallback(() => { + navigation.navigate('Profile', {name: profile.did}) + }, [navigation, profile.did]) + + const {mutate: muteConvo} = useMuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat muted`)) + }, + onError: () => { + Toast.show(_(msg`Could not mute chat`)) + }, + }) + + const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat unmuted`)) + }, + onError: () => { + Toast.show(_(msg`Could not unmute chat`)) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onSuccess: () => { + if (currentScreen === 'conversation') { + navigation.replace('Messages') + } + }, + onError: () => { + Toast.show(_(msg`Could not leave chat`)) + }, + }) + + return ( + <> + + {!hideTrigger && ( + + {({props, state}) => ( + { + Keyboard.dismiss() + // eslint-disable-next-line react/prop-types -- eslint is confused by the name `props` + props.onPress() + }} + style={[ + a.p_sm, + a.rounded_sm, + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, + // make sure pfp is in the middle + {marginLeft: -10}, + ]}> + + + )} + + )} + + + + + Go to profile + + + + (convo?.muted ? unmuteConvo() : muteConvo())}> + + {convo?.muted ? ( + Unmute notifications + ) : ( + Mute notifications + )} + + + + + + {/* TODO(samuel): implement these */} + + {}} + disabled> + + Block account + + + + {}} + disabled> + + Report account + + + + + + + + + Leave conversation + + + + + + + + leaveConvo()} + /> + + ) +} +ConvoMenu = React.memo(ConvoMenu) + +export {ConvoMenu} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx new file mode 100644 index 0000000000..f8f5197ca4 --- /dev/null +++ b/src/components/dms/MessageItem.tsx @@ -0,0 +1,198 @@ +import React, {useCallback, useMemo, useRef} from 'react' +import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useSession} from '#/state/session' +import {TimeElapsed} from 'view/com/util/TimeElapsed' +import {atoms as a, useTheme} from '#/alf' +import {ActionsWrapper} from '#/components/dms/ActionsWrapper' +import {Text} from '#/components/Typography' + +export let MessageItem = ({ + item, + next, + pending, +}: { + item: ChatBskyConvoDefs.MessageView + next: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + pending?: boolean +}): React.ReactNode => { + const t = useTheme() + const {currentAccount} = useSession() + + const isFromSelf = item.sender?.did === currentAccount?.did + + const isNextFromSelf = + ChatBskyConvoDefs.isMessageView(next) && + next.sender?.did === currentAccount?.did + + const isLastInGroup = useMemo(() => { + // TODO this means it's a placeholder. Let's figure out the right way to do this though! + if (item.id.length > 13) { + return false + } + + // if the next message is from a different sender, then it's the last in the group + if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { + return true + } + + // or, if there's a 3 minute gap between this message and the next + if (ChatBskyConvoDefs.isMessageView(next)) { + const thisDate = new Date(item.sentAt) + const nextDate = new Date(next.sentAt) + + const diff = nextDate.getTime() - thisDate.getTime() + + // 3 minutes + return diff > 3 * 60 * 1000 + } + + return true + }, [item, next, isFromSelf, isNextFromSelf]) + + const lastInGroupRef = useRef(isLastInGroup) + if (lastInGroupRef.current !== isLastInGroup) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + lastInGroupRef.current = isLastInGroup + } + + const pendingColor = + t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800 + + return ( + + + + + {item.text} + + + + + + ) +} + +MessageItem = React.memo(MessageItem) + +let MessageItemMetadata = ({ + message, + isLastInGroup, + style, +}: { + message: ChatBskyConvoDefs.MessageView + isLastInGroup: boolean + style: StyleProp +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + + const relativeTimestamp = useCallback( + (timestamp: string) => { + const date = new Date(timestamp) + const now = new Date() + + const time = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }).format(date) + + const diff = now.getTime() - date.getTime() + + // if under 1 minute + if (diff < 1000 * 60) { + return _(msg`Now`) + } + + // if in the last day + if (localDateString(now) === localDateString(date)) { + return time + } + + // if yesterday + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + + if (localDateString(yesterday) === localDateString(date)) { + return _(msg`Yesterday, ${time}`) + } + + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + day: 'numeric', + month: 'numeric', + year: 'numeric', + }).format(date) + }, + [_], + ) + + if (!isLastInGroup) { + return null + } + + return ( + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + + ) +} + +MessageItemMetadata = React.memo(MessageItemMetadata) + +function localDateString(date: Date) { + // can't use toISOString because it should be in local time + const mm = date.getMonth() + const dd = date.getDate() + const yyyy = date.getFullYear() + // not padding with 0s because it's not necessary, it's just used for comparison + return `${yyyy}-${mm}-${dd}` +} diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx new file mode 100644 index 0000000000..d2a7d147d3 --- /dev/null +++ b/src/components/dms/MessageMenu.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import {LayoutAnimation, Pressable, View} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useChat} from 'state/messages' +import {ConvoStatus} from 'state/messages/convo' +import {useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {usePromptControl} from '#/components/Prompt' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard' + +export let MessageMenu = ({ + message, + control, + hideTrigger, + triggerOpacity, +}: { + hideTrigger?: boolean + triggerOpacity?: number + onTriggerPress?: () => void + message: ChatBskyConvoDefs.MessageView + control: Menu.MenuControlProps +}): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const chat = useChat() + const deleteControl = usePromptControl() + const retryDeleteControl = usePromptControl() + + const isFromSelf = message.sender?.did === currentAccount?.did + + const onCopyPostText = React.useCallback(() => { + // use when we have rich text + // const str = richTextToString(richText, true) + + Clipboard.setStringAsync(message.text) + Toast.show(_(msg`Copied to clipboard`)) + }, [_, message.text]) + + const onDelete = React.useCallback(() => { + if (chat.status !== ConvoStatus.Ready) return + + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + chat + .deleteMessage(message.id) + .then(() => Toast.show(_(msg`Message deleted`))) + .catch(() => retryDeleteControl.open()) + }, [_, chat, message.id, retryDeleteControl]) + + const onReport = React.useCallback(() => { + // TODO report the message + }, []) + + return ( + <> + + {!hideTrigger && ( + + + {({props, state}) => ( + + + + )} + + + )} + + + + + {_(msg`Copy message text`)} + + + + + + + {_(msg`Delete for me`)} + + + {!isFromSelf && ( + + {_(msg`Report`)} + + + )} + + + + + + + + + ) +} +MessageMenu = React.memo(MessageMenu) diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx new file mode 100644 index 0000000000..5dde8628c1 --- /dev/null +++ b/src/components/dms/NewChat.tsx @@ -0,0 +1,255 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import {Keyboard, View} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {FAB} from '#/view/com/util/fab/FAB' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Button} from '../Button' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' +import {ListMaybePlaceholder} from '../Lists' +import {Text} from '../Typography' + +export function NewChat({ + control, + onNewChat, +}: { + control: Dialog.DialogControlProps + onNewChat: (chatId: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + + const {mutate: createChat} = useGetConvoForMembers({ + onSuccess: data => { + onNewChat(data.convo.id) + }, + onError: error => { + Toast.show(error.message) + }, + }) + + const onCreateChat = useCallback( + (did: string) => { + control.close(() => createChat([did])) + }, + [control, createChat], + ) + + return ( + <> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + + + + + + ) +} + +function SearchablePeopleList({ + onCreateChat, +}: { + onCreateChat: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const listRef = useRef(null) + + const [searchText, setSearchText] = useState('') + + const { + data: actorAutocompleteData, + isFetching, + isError, + refetch, + } = useActorAutocompleteQuery(searchText, true) + + const renderItem = useCallback( + ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => { + if (!moderationOpts) return null + const moderation = moderateProfile(profile, moderationOpts) + return ( + + ) + }, + [ + moderationOpts, + onCreateChat, + t.atoms.bg_contrast_25, + t.atoms.bg_contrast_50, + t.atoms.bg, + t.atoms.text, + t.atoms.text_contrast_high, + ], + ) + + const listHeader = useMemo(() => { + return ( + + {/* cover top corners */} + + + + Start a new chat + + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + returnKeyType="search" + clearButtonMode="while-editing" + maxLength={50} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + control.close() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + /> + + + ) + }, [t.atoms.bg, _, control, searchText]) + + return ( + + {listHeader} + {searchText.length === 0 ? ( + + + + Search for someone to start a conversation with. + + + ) : ( + !actorAutocompleteData?.length && ( + + ) + )} + + } + stickyHeaderIndices={[0]} + keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did} + // @ts-expect-error web only + style={isWeb && {minHeight: '100vh'}} + onScrollBeginDrag={() => Keyboard.dismiss()} + /> + ) +} diff --git a/src/components/hooks/useRefreshOnFocus.ts b/src/components/hooks/useRefreshOnFocus.ts new file mode 100644 index 0000000000..6bf7ac8b1f --- /dev/null +++ b/src/components/hooks/useRefreshOnFocus.ts @@ -0,0 +1,17 @@ +import {useCallback, useRef} from 'react' +import {useFocusEffect} from '@react-navigation/native' + +export function useRefreshOnFocus(refetch: () => Promise) { + const firstTimeRef = useRef(true) + + useFocusEffect( + useCallback(() => { + if (firstTimeRef.current) { + firstTimeRef.current = false + return + } + + refetch() + }, [refetch]), + ) +} diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx new file mode 100644 index 0000000000..011bf6afa3 --- /dev/null +++ b/src/components/icons/ArrowBoxLeft.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/PaperPlane.tsx b/src/components/icons/PaperPlane.tsx new file mode 100644 index 0000000000..eef38638cb --- /dev/null +++ b/src/components/icons/PaperPlane.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z', +}) diff --git a/src/components/icons/Plus.tsx b/src/components/icons/Plus.tsx index d0698f7f44..71bcee533c 100644 --- a/src/components/icons/Plus.tsx +++ b/src/components/icons/Plus.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z', }) + +export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z', +}) diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index af265bfcb6..00b0d7ecaa 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -4,7 +4,17 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' -const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' -export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${ - IS_DEV ? 'development' : UPDATES_CHANNEL +// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings +// along with the other version info. Useful for debugging/reporting. +export const BUNDLE_IDENTIFIER = + process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev' + +// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used +// for Statsig reporting and shouldn't be used to identify a specific bundle. +export const BUNDLE_DATE = + IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) + +export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}` +export const bundleInfo = `${BUNDLE_IDENTIFIER} (${ + IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod' })` diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts index 5739b8783a..fe2bc5fff8 100644 --- a/src/lib/app-info.web.ts +++ b/src/lib/app-info.web.ts @@ -1,2 +1,17 @@ import {version} from '../../package.json' + +export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' + +// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings +// along with the other version info. Useful for debugging/reporting. +export const BUNDLE_IDENTIFIER = + process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev' + +// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used +// for Statsig reporting and shouldn't be used to identify a specific bundle. +export const BUNDLE_DATE = IS_DEV + ? 0 + : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) + export const appVersion = version +export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})` diff --git a/src/lib/functions.ts b/src/lib/functions.ts index b45c7fa6d1..e0d44ce2d7 100644 --- a/src/lib/functions.ts +++ b/src/lib/functions.ts @@ -9,3 +9,90 @@ export function dedupArray(arr: T[]): T[] { const s = new Set(arr) return [...s] } + +/** + * Taken from @tanstack/query-core utils.ts + * Modified to support Date object comparisons + * + * This function returns `a` if `b` is deeply equal. + * If not, it will replace any deeply equal children of `b` with those of `a`. + * This can be used for structural sharing between JSON values for example. + */ +export function replaceEqualDeep(a: any, b: any): any { + if (a === b) { + return a + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime() ? a : b + } + + const array = isPlainArray(a) && isPlainArray(b) + + if (array || (isPlainObject(a) && isPlainObject(b))) { + const aItems = array ? a : Object.keys(a) + const aSize = aItems.length + const bItems = array ? b : Object.keys(b) + const bSize = bItems.length + const copy: any = array ? [] : {} + + let equalItems = 0 + + for (let i = 0; i < bSize; i++) { + const key = array ? i : bItems[i] + if ( + !array && + a[key] === undefined && + b[key] === undefined && + aItems.includes(key) + ) { + copy[key] = undefined + equalItems++ + } else { + copy[key] = replaceEqualDeep(a[key], b[key]) + if (copy[key] === a[key] && a[key] !== undefined) { + equalItems++ + } + } + } + + return aSize === bSize && equalItems === aSize ? a : copy + } + + return b +} + +export function isPlainArray(value: unknown) { + return Array.isArray(value) && value.length === Object.keys(value).length +} + +// Copied from: https://github.com/jonschlinkert/is-plain-object +export function isPlainObject(o: any): o is Object { + if (!hasObjectPrototype(o)) { + return false + } + + // If has no constructor + const ctor = o.constructor + if (ctor === undefined) { + return true + } + + // If has modified prototype + const prot = ctor.prototype + if (!hasObjectPrototype(prot)) { + return false + } + + // If constructor does not have an Object-specific method + if (!prot.hasOwnProperty('isPrototypeOf')) { + return false + } + + // Most likely a plain Object + return true +} + +function hasObjectPrototype(o: any): boolean { + return Object.prototype.toString.call(o) === '[object Object]' +} diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 6a1cea2345..ad529f9121 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -1,15 +1,21 @@ -import {useCallback} from 'react' +import {useCallback, useState} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' +import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {SessionAccount, useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import * as Toast from '#/view/com/util/Toast' +import {logEvent} from '../statsig/statsig' import {LogEvents} from '../statsig/statsig' export function useAccountSwitcher() { + const [pendingDid, setPendingDid] = useState(null) + const {_} = useLingui() const {track} = useAnalytics() - const {selectAccount, clearCurrentAccount} = useSessionApi() + const {initSession} = useSessionApi() const {requestSwitchToAccount} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( @@ -18,8 +24,12 @@ export function useAccountSwitcher() { logContext: LogEvents['account:loggedIn']['logContext'], ) => { track('Settings:SwitchAccountButtonClicked') - + if (pendingDid) { + // The session API isn't resilient to race conditions so let's just ignore this. + return + } try { + setPendingDid(account.did) if (account.accessJwt) { if (isWeb) { // We're switching accounts, which remounts the entire app. @@ -29,24 +39,26 @@ export function useAccountSwitcher() { // So we change the URL ourselves. The navigator will pick it up on remount. history.pushState(null, '', '/') } - await selectAccount(account, logContext) - setTimeout(() => { - Toast.show(`Signed in as @${account.handle}`) - }, 100) + await initSession(account) + logEvent('account:loggedIn', {logContext, withPassword: false}) + Toast.show(_(msg`Signed in as @${account.handle}`)) } else { requestSwitchToAccount({requestedAccount: account.did}) Toast.show( - `Please sign in as @${account.handle}`, + _(msg`Please sign in as @${account.handle}`), 'circle-exclamation', ) } - } catch (e) { - Toast.show('Sorry! We need you to enter your password.') - clearCurrentAccount() // back user out to login + } catch (e: any) { + logger.error(`switch account: selectAccount failed`, { + message: e.message, + }) + } finally { + setPendingDid(null) } }, - [track, clearCurrentAccount, selectAccount, requestSwitchToAccount], + [_, track, initSession, requestSwitchToAccount, pendingDid], ) - return {onPressSwitchAccount} + return {onPressSwitchAccount, pendingDid} } diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts index 7fc0c65beb..c70653e3a7 100644 --- a/src/lib/hooks/useNavigationTabState.ts +++ b/src/lib/hooks/useNavigationTabState.ts @@ -11,8 +11,9 @@ export function useNavigationTabState() { isAtNotifications: getTabState(state, 'Notifications') !== TabState.Outside, isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, - isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside, + isAtMessages: getTabState(state, 'Messages') !== TabState.Outside, } + if ( !res.isAtHome && !res.isAtSearch && diff --git a/src/lib/hooks/useNavigationTabState.web.ts b/src/lib/hooks/useNavigationTabState.web.ts index 704424781a..e86d6c6c37 100644 --- a/src/lib/hooks/useNavigationTabState.web.ts +++ b/src/lib/hooks/useNavigationTabState.web.ts @@ -1,4 +1,5 @@ import {useNavigationState} from '@react-navigation/native' + import {getCurrentRoute} from 'lib/routes/helpers' export function useNavigationTabState() { @@ -9,6 +10,7 @@ export function useNavigationTabState() { isAtSearch: currentRoute === 'Search', isAtNotifications: currentRoute === 'Notifications', isAtMyProfile: currentRoute === 'MyProfile', + isAtMessages: currentRoute === 'Messages', } }) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index f9a5927119..f7e8544b8b 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & { } export type MessagesTabNavigatorParams = CommonNavigatorParams & { - MessagesList: undefined + Messages: undefined } export type FlatNavigatorParams = CommonNavigatorParams & { @@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Feeds: undefined Notifications: undefined Hashtag: {tag: string; author?: string} - MessagesList: undefined + Messages: undefined } export type AllNavigatorParams = CommonNavigatorParams & { @@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { MyProfileTab: undefined Hashtag: {tag: string; author?: string} MessagesTab: undefined - MessagesList: undefined + Messages: undefined } // NOTE diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 5cd603920e..43e2086c24 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,10 +1,9 @@ export type Gate = // Keep this alphabetic please. | 'autoexpand_suggestions_on_profile_follow_v2' - | 'disable_min_shell_on_foregrounding_v2' + | 'disable_min_shell_on_foregrounding_v3' | 'disable_poll_on_discover_v2' | 'dms' - | 'hide_vertical_scroll_indicators' | 'show_follow_back_label_v2' | 'start_session_with_following_v2' | 'test_gate_1' diff --git a/src/locale/locales/ca/messages.po b/src/locale/locales/ca/messages.po index adee4e3a57..e3aaa7a6c9 100644 --- a/src/locale/locales/ca/messages.po +++ b/src/locale/locales/ca/messages.po @@ -777,11 +777,11 @@ msgstr "Canvia el teu correu" #: src/Navigation.tsx:302 msgid "Chat" -msgstr "" +msgstr "Xat" #: src/screens/Messages/Conversation/index.tsx:26 msgid "Chat with {chatId}" -msgstr "" +msgstr "Xateja amb {chatId}" #: src/screens/Deactivated.tsx:79 #: src/screens/Deactivated.tsx:83 @@ -2858,18 +2858,18 @@ msgstr "Missatge del servidor: {0}" #: src/screens/Messages/List/index.tsx:34 msgid "Message settings" -msgstr "" +msgstr "Configuració dels missatges" #: src/Navigation.tsx:517 #: src/screens/Messages/List/index.tsx:103 #: src/view/shell/bottom-bar/BottomBar.tsx:261 #: src/view/shell/desktop/LeftNav.tsx:360 msgid "Messages" -msgstr "" +msgstr "Missatges" #: src/Navigation.tsx:307 msgid "Messaging settings" -msgstr "" +msgstr "Configuració dels missatges" #: src/lib/moderation/useReportOptions.ts:45 msgid "Misleading Account" @@ -3569,7 +3569,7 @@ msgstr "Obre la web enllaçada" #: src/screens/Messages/List/index.tsx:35 msgid "Opens the message settings page" -msgstr "" +msgstr "Obre la pàgina de configuració dels missatges" #: src/view/screens/Settings/index.tsx:788 #: src/view/screens/Settings/index.tsx:798 @@ -4044,7 +4044,7 @@ msgstr "Elimina la paraula silenciada de la teva llista" #: src/view/com/util/post-embeds/QuoteEmbed.tsx:208 msgid "Remove quote" -msgstr "" +msgstr "Elimina la citació" #: src/view/com/modals/Repost.tsx:48 msgid "Remove repost" @@ -4081,7 +4081,7 @@ msgstr "Elimina la miniatura per defecte de {0}" #: src/view/com/util/post-embeds/QuoteEmbed.tsx:209 msgid "Removes quoted post" -msgstr "" +msgstr "Elimina la publicació amb la citació" #: src/view/screens/Profile.tsx:194 msgid "Replies" @@ -4413,7 +4413,7 @@ msgstr "Cerca per \"{query}\"" #: src/view/screens/Search/Search.tsx:839 msgid "Search for \"{searchText}\"" -msgstr "" +msgstr "Cerca per \"{searchText}\"" #: src/components/TagMenu/index.tsx:145 msgid "Search for all posts by @{authorHandle} with tag {displayTag}" @@ -6261,7 +6261,7 @@ msgstr "No tens llistes." #: src/screens/Messages/List/index.tsx:92 msgid "You have no messages yet. Start a conversation with someone!" -msgstr "" +msgstr "Encara no tens missatges. Comença una conversa amb algú!" #: src/view/screens/ModerationBlockedAccounts.tsx:137 msgid "You have not blocked any accounts yet. To block an account, go to their profile and select \"Block account\" from the menu on their account." diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 7f268f0fc9..a5b6ce0e02 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-04-27 16:07+0900\n" +"PO-Revision-Date: 2024-05-06 15:47+0900\n" "Last-Translator: tkusano\n" "Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n" "Plural-Forms: \n" @@ -247,6 +247,10 @@ msgstr "以前のメールアドレス{0}にメールが送信されました。 msgid "An error occured" msgstr "エラーが発生しました" +#: src/components/dms/MessageMenu.tsx:132 +msgid "An error occurred while trying to delete the message. Please try again." +msgstr "メッセージ削除中にエラーが発生しました。再実行してみてください。" + #: src/lib/moderation/useReportOptions.ts:26 msgid "An issue not included in these options" msgstr "ほかの選択肢にはあてはまらない問題" @@ -324,6 +328,14 @@ msgstr "背景" msgid "Are you sure you want to delete the app password \"{name}\"?" msgstr "アプリパスワード「{name}」を本当に削除しますか?" +#: src/components/dms/MessageMenu.tsx:90 +msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for other participants." +msgstr "このメッセージを本当に削除しますか?このメッセージはあなたからは削除したように見えますが、他の参加者からは削除されません。" + +#: src/components/dms/ConvoMenu.tsx:166 +msgid "Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants." +msgstr "この会話から退出しますか?あなたのメッセージはあなたからは削除したように見えますが、他の参加者からは削除されません。" + #: src/view/com/feeds/FeedSourceCard.tsx:280 msgid "Are you sure you want to remove {0} from your feeds?" msgstr "あなたのフィードから{0}を削除してもよろしいですか?" @@ -389,6 +401,11 @@ msgstr "生年月日:" msgid "Block" msgstr "ブロック" +#: src/components/dms/ConvoMenu.tsx:129 +#: src/components/dms/ConvoMenu.tsx:133 +msgid "Block account" +msgstr "アカウントをブロック" + #: src/view/com/profile/ProfileMenu.tsx:300 #: src/view/com/profile/ProfileMenu.tsx:307 msgid "Block Account" @@ -617,9 +634,17 @@ msgstr "メールアドレスを変更" msgid "Chat" msgstr "チャット" -#: src/screens/Messages/Conversation/index.tsx:26 -msgid "Chat with {chatId}" -msgstr "{chatId}とのチャット" +#: src/components/dms/ConvoMenu.tsx:55 +msgid "Chat muted" +msgstr "チャットをミュートしました" + +#: src/components/dms/ConvoMenu.tsx:87 +msgid "Chat settings" +msgstr "チャットの設定" + +#: src/components/dms/ConvoMenu.tsx:65 +msgid "Chat unmuted" +msgstr "チャットのミュートを解除しました" #: src/screens/Deactivated.tsx:79 #: src/screens/Deactivated.tsx:83 @@ -966,6 +991,11 @@ msgstr "リストへのリンクをコピー" msgid "Copy link to post" msgstr "投稿へのリンクをコピー" +#: src/components/dms/MessageMenu.tsx:89 +#: src/components/dms/MessageMenu.tsx:91 +msgid "Copy message text" +msgstr "メッセージのテキストをコピー" + #: src/view/com/util/forms/PostDropdownBtn.tsx:230 #: src/view/com/util/forms/PostDropdownBtn.tsx:232 msgid "Copy post text" @@ -976,6 +1006,10 @@ msgstr "投稿のテキストをコピー" msgid "Copyright Policy" msgstr "著作権ポリシー" +#: src/components/dms/ConvoMenu.tsx:79 +msgid "Could not leave chat" +msgstr "チャットからの退出に失敗しました" + #: src/view/screens/ProfileFeed.tsx:103 msgid "Could not load feed" msgstr "フィードの読み込みに失敗しました" @@ -984,6 +1018,18 @@ msgstr "フィードの読み込みに失敗しました" msgid "Could not load list" msgstr "リストの読み込みに失敗しました" +#: src/components/dms/NewChat.tsx:225 +msgid "Could not load profiles. Please try again later." +msgstr "プロフィールの読み込みに失敗しました。時間をおいてもう一度お試しください。" + +#: src/components/dms/ConvoMenu.tsx:58 +msgid "Could not mute chat" +msgstr "チャットのミュートに失敗しました" + +#: src/components/dms/ConvoMenu.tsx:68 +msgid "Could not unmute chat" +msgstr "チャットのミュートの解除に失敗しました" + #: src/view/com/auth/SplashScreen.tsx:57 #: src/view/com/auth/SplashScreen.web.tsx:101 msgid "Create a new account" @@ -1088,10 +1134,23 @@ msgstr "アプリパスワードを削除" msgid "Delete app password?" msgstr "アプリパスワードを削除しますか?" +#: src/components/dms/MessageMenu.tsx:101 +msgid "Delete for me" +msgstr "自分宛を削除" + #: src/view/screens/ProfileList.tsx:417 msgid "Delete List" msgstr "リストを削除" +#: src/components/dms/MessageMenu.tsx:68 +#: src/components/dms/MessageMenu.tsx:88 +msgid "Delete message" +msgstr "メッセージを削除" + +#: src/components/dms/MessageMenu.tsx:99 +msgid "Delete message for me" +msgstr "メッセージの宛先から自分を削除" + #: src/view/com/modals/DeleteAccount.tsx:223 msgid "Delete my account" msgstr "マイアカウントを削除" @@ -1584,6 +1643,10 @@ msgstr "アプリパスワードの作成に失敗しました。" msgid "Failed to create the list. Check your internet connection and try again." msgstr "リストの作成に失敗しました。インターネットへの接続を確認の上、もう一度お試しください。" +#: src/components/dms/MessageMenu.tsx:130 +msgid "Failed to delete message" +msgstr "メッセージの削除に失敗しました" + #: src/view/com/util/forms/PostDropdownBtn.tsx:131 msgid "Failed to delete post, please try again" msgstr "投稿の削除に失敗しました。もう一度お試しください。" @@ -1592,6 +1655,10 @@ msgstr "投稿の削除に失敗しました。もう一度お試しください msgid "Failed to load GIFs" msgstr "GIFの読み込みに失敗しました" +#: src/screens/Messages/Conversation/MessageListError.tsx:21 +msgid "Failed to load past messages." +msgstr "過去のメッセージの読み込みに失敗しました。" + #: src/view/com/lightbox/Lightbox.tsx:83 msgid "Failed to save image: {0}" msgstr "画像の保存に失敗しました:{0}" @@ -1853,16 +1920,19 @@ msgstr "ホームへ" msgid "Go Home" msgstr "ホームへ" -#: src/view/screens/Search/Search.tsx:827 -#: src/view/shell/desktop/Search.tsx:263 -#~ msgid "Go to @{queryMaybeHandle}" -#~ msgstr "@{queryMaybeHandle}へ" - #: src/screens/Login/ForgotPasswordForm.tsx:172 #: src/view/com/modals/ChangePassword.tsx:169 msgid "Go to next" msgstr "次へ" +#: src/components/dms/ConvoMenu.tsx:109 +msgid "Go to profile" +msgstr "プロフィールへ" + +#: src/components/dms/ConvoMenu.tsx:106 +msgid "Go to user's profile" +msgstr "ユーザーのプロフィールへ移動" + #: src/lib/moderation/useGlobalLabelStrings.ts:46 msgid "Graphic Media" msgstr "生々しいメディア" @@ -2218,6 +2288,16 @@ msgstr "Blueskyで公開されている内容はこちらを参照してくだ msgid "Learn more." msgstr "詳細。" +#: src/components/dms/ConvoMenu.tsx:168 +msgid "Leave" +msgstr "退出" + +#: src/components/dms/ConvoMenu.tsx:151 +#: src/components/dms/ConvoMenu.tsx:154 +#: src/components/dms/ConvoMenu.tsx:164 +msgid "Leave conversation" +msgstr "会話を退出" + #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 msgid "Leave them all unchecked to see any language." msgstr "どの言語も表示するには、すべてのチェックを外したままにします。" @@ -2411,10 +2491,18 @@ msgstr "メンションされたユーザー" msgid "Menu" msgstr "メニュー" +#: src/screens/Messages/List/index.tsx:209 +msgid "Message deleted" +msgstr "メッセージは削除されました" + #: src/view/com/posts/FeedErrorMessage.tsx:192 msgid "Message from server: {0}" msgstr "サーバーからのメッセージ:{0}" +#: src/screens/Messages/Conversation/MessageInput.tsx:74 +msgid "Message input field" +msgstr "メッセージを入力するフィールド" + #: src/screens/Messages/List/index.tsx:34 msgid "Message settings" msgstr "メッセージの設定" @@ -2543,6 +2631,11 @@ msgstr "テキストとタグをミュート" msgid "Mute list" msgstr "リストをミュート" +#: src/components/dms/ConvoMenu.tsx:114 +#: src/components/dms/ConvoMenu.tsx:120 +msgid "Mute notifications" +msgstr "通知をミュート" + #: src/view/screens/ProfileList.tsx:621 msgid "Mute these accounts?" msgstr "これらのアカウントをミュートしますか?" @@ -2665,6 +2758,12 @@ msgstr "新規" msgid "New" msgstr "新規" +#: src/components/dms/NewChat.tsx:59 +#: src/screens/Messages/List/index.tsx:346 +#: src/screens/Messages/List/index.tsx:354 +msgid "New chat" +msgstr "新しいチャット" + #: src/view/com/modals/CreateOrEditList.tsx:255 msgid "New Moderation List" msgstr "新しいモデレーションリスト" @@ -2755,6 +2854,11 @@ msgstr "{0}のフォローを解除しました" msgid "No longer than 253 characters" msgstr "253文字まで" +#: src/screens/Messages/List/index.tsx:138 +#: src/screens/Messages/List/index.tsx:198 +msgid "No messages yet" +msgstr "メッセージはありません" + #: src/view/com/notifications/Feed.tsx:109 msgid "No notifications yet!" msgstr "お知らせはありません!" @@ -2780,7 +2884,11 @@ msgstr "「{query}」の検索結果はありません" #: src/components/dialogs/GifSelect.tsx:204 msgid "No search results found for \"{search}\"." -msgstr "「{search}」の検索結果はありません" +msgstr "「{search}」の検索結果はありません。" + +#: src/components/dms/NewChat.tsx:224 +msgid "No search results found for \"{searchText}\"." +msgstr "「{searchText}」の検索結果はありません。" #: src/components/dialogs/EmbedConsent.tsx:105 #: src/components/dialogs/EmbedConsent.tsx:112 @@ -2834,6 +2942,10 @@ msgstr "注記:Blueskyはオープンでパブリックなネットワーク msgid "Notifications" msgstr "通知" +#: src/screens/Messages/Conversation/MessageItem.tsx:116 +msgid "Now" +msgstr "今" + #: src/view/com/modals/SelfLabel.tsx:103 msgid "Nudity" msgstr "ヌード" @@ -3218,6 +3330,10 @@ msgstr "パスワードも入力してください:" msgid "Please explain why you think this label was incorrectly applied by {0}" msgstr "{0}によって貼られたこのラベルが誤って適用されたと思われる理由を説明してください" +#: src/lib/hooks/useAccountSwitcher.ts:43 +msgid "Please sign in as @{0}" +msgstr "@{0}としてサインインしてください" + #: src/view/com/modals/VerifyEmail.tsx:110 msgid "Please Verify Your Email" msgstr "メールアドレスを確認してください" @@ -3316,6 +3432,11 @@ msgstr "ホスティングプロバイダーを変える" msgid "Press to retry" msgstr "再実行する" +#: src/screens/Messages/Conversation/MessagesList.tsx:41 +#: src/screens/Messages/Conversation/MessagesList.tsx:47 +msgid "Press to Retry" +msgstr "再実行" + #: src/view/com/lightbox/Lightbox.web.tsx:150 msgid "Previous image" msgstr "前の画像" @@ -3520,6 +3641,15 @@ msgctxt "description" msgid "Reply to <0><1/>" msgstr "<0><1/>に返信" +#: src/components/dms/MessageMenu.tsx:78 +msgid "Report" +msgstr "報告" + +#: src/components/dms/ConvoMenu.tsx:140 +#: src/components/dms/ConvoMenu.tsx:144 +msgid "Report account" +msgstr "アカウントを報告" + #: src/view/com/profile/ProfileMenu.tsx:319 #: src/view/com/profile/ProfileMenu.tsx:322 msgid "Report Account" @@ -3538,6 +3668,10 @@ msgstr "フィードを報告" msgid "Report List" msgstr "リストを報告" +#: src/components/dms/MessageMenu.tsx:76 +msgid "Report message" +msgstr "メッセージを報告" + #: src/view/com/util/forms/PostDropdownBtn.tsx:316 #: src/view/com/util/forms/PostDropdownBtn.tsx:318 msgid "Report post" @@ -3678,6 +3812,10 @@ msgstr "エラーになった最後のアクションをやり直す" msgid "Retry" msgstr "再試行" +#: src/screens/Messages/Conversation/MessageListError.tsx:48 +msgid "Retry." +msgstr "再試行。" + #: src/components/Error.tsx:95 #: src/view/screens/ProfileList.tsx:919 msgid "Return to previous page" @@ -3795,6 +3933,10 @@ msgstr "{displayTag}のすべての投稿を検索(@{authorHandle}のみ)" msgid "Search for all posts with tag {displayTag}" msgstr "{displayTag}のすべての投稿を検索(すべてのユーザー)" +#: src/components/dms/NewChat.tsx:226 +msgid "Search for someone to start a conversation with." +msgstr "会話を始める相手を検索。" + #: src/view/com/auth/LoggedOut.tsx:105 #: src/view/com/auth/LoggedOut.tsx:106 #: src/view/com/modals/ListAddRemoveUsers.tsx:70 @@ -3805,6 +3947,10 @@ msgstr "ユーザーを検索" msgid "Search GIFs" msgstr "GIFを検索" +#: src/components/dms/NewChat.tsx:182 +msgid "Search profiles" +msgstr "プロフィールを検索" + #: src/components/dialogs/GifSelect.tsx:159 msgid "Search Tenor" msgstr "Tenorを検索" @@ -3937,6 +4083,10 @@ msgstr "メールを送信" msgid "Send feedback" msgstr "フィードバックを送信" +#: src/screens/Messages/Conversation/MessageInput.tsx:94 +msgid "Send message" +msgstr "メッセージを送信" + #: src/components/ReportDialog/SubmitView.tsx:215 #: src/components/ReportDialog/SubmitView.tsx:219 msgid "Send report" @@ -4309,8 +4459,12 @@ msgstr "スポーツ" msgid "Square" msgstr "正方形" -#: src/view/screens/Settings/index.tsx:862 -msgid "Status page" +#: src/components/dms/NewChat.tsx:177 +msgid "Start a new chat" +msgstr "新しいチャットを開始" + +#: src/view/screens/Settings/index.tsx:896 +msgid "Status Page" msgstr "ステータスページ" #: src/screens/Signup/index.tsx:145 @@ -4799,6 +4953,10 @@ msgstr "再試行" msgid "Two-factor authentication" msgstr "2要素認証" +#: src/screens/Messages/Conversation/MessageInput.tsx:75 +msgid "Type your message here" +msgstr "ここにメッセージを入力する" + #: src/view/com/modals/ChangeHandle.tsx:429 msgid "Type:" msgstr "タイプ:" @@ -4893,6 +5051,10 @@ msgstr "アカウントのミュートを解除" msgid "Unmute all {displayTag} posts" msgstr "{displayTag}のすべての投稿のミュートを解除" +#: src/components/dms/ConvoMenu.tsx:118 +msgid "Unmute notifications" +msgstr "通知のミュートを解除" + #: src/view/com/util/forms/PostDropdownBtn.tsx:273 #: src/view/com/util/forms/PostDropdownBtn.tsx:278 msgid "Unmute thread" @@ -5093,9 +5255,9 @@ msgstr "新しいメールアドレスを確認" msgid "Verify Your Email" msgstr "メールアドレスを確認" -#: src/view/screens/Settings/index.tsx:852 -msgid "Version {0}" -msgstr "バージョン {0}" +#: src/view/screens/Settings/index.tsx:868 +msgid "Version {appVersion} {bundleInfo}" +msgstr "バージョン {appVersion} {bundleInfo}" #: src/screens/Onboarding/index.tsx:42 msgid "Video Games" @@ -5279,6 +5441,11 @@ msgstr "なぜこのユーザーをレビューする必要がありますか? msgid "Wide" msgstr "ワイド" +#: src/screens/Messages/Conversation/MessageInput.tsx:76 +#: src/screens/Messages/Conversation/MessageInput.web.tsx:71 +msgid "Write a message" +msgstr "メッセージを書く" + #: src/view/com/composer/Composer.tsx:468 msgid "Write post" msgstr "投稿を書く" @@ -5302,6 +5469,10 @@ msgstr "ライター" msgid "Yes" msgstr "はい" +#: src/screens/Messages/Conversation/MessageItem.tsx:130 +msgid "Yesterday, {time}" +msgstr "昨日、{time}" + #: src/screens/Deactivated.tsx:137 msgid "You are in line." msgstr "あなたは並んでいます。" @@ -5435,6 +5606,10 @@ msgstr "これ以降、このスレッドに関する通知を受け取ること msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." msgstr "「リセットコード」が記載されたメールが届きます。ここにコードを入力し、新しいパスワードを入力します。" +#: src/screens/Messages/List/index.tsx:202 +msgid "You: {0}" +msgstr "あなた: {0}" + #: src/screens/Onboarding/StepModeration/index.tsx:60 msgid "You're in control" msgstr "あなたがコントロールしています" diff --git a/src/locale/locales/ko/messages.po b/src/locale/locales/ko/messages.po index 26484482a2..eb2bfb3293 100644 --- a/src/locale/locales/ko/messages.po +++ b/src/locale/locales/ko/messages.po @@ -22,7 +22,7 @@ msgstr "(이메일 없음)" msgid "{following} following" msgstr "{following} 팔로우 중" -#: src/view/shell/Drawer.tsx:441 +#: src/view/shell/Drawer.tsx:454 msgid "{numUnreadNotifications} unread" msgstr "{numUnreadNotifications}개 읽지 않음" @@ -30,7 +30,7 @@ msgstr "{numUnreadNotifications}개 읽지 않음" msgid "<0/> members" msgstr "<0/>의 멤버" -#: src/view/shell/Drawer.tsx:96 +#: src/view/shell/Drawer.tsx:97 msgid "<0>{0} following" msgstr "<0>{0} 팔로우 중" @@ -61,11 +61,11 @@ msgid "Access profile and other navigation links" msgstr "프로필 및 기타 탐색 링크로 이동합니다" #: src/view/com/modals/EditImage.tsx:300 -#: src/view/screens/Settings/index.tsx:493 +#: src/view/screens/Settings/index.tsx:509 msgid "Accessibility" msgstr "접근성" -#: src/view/screens/Settings/index.tsx:484 +#: src/view/screens/Settings/index.tsx:500 msgid "Accessibility settings" msgstr "접근성 설정" @@ -79,8 +79,8 @@ msgid "account" msgstr "계정" #: src/screens/Login/LoginForm.tsx:161 -#: src/view/screens/Settings/index.tsx:323 -#: src/view/screens/Settings/index.tsx:702 +#: src/view/screens/Settings/index.tsx:336 +#: src/view/screens/Settings/index.tsx:718 msgid "Account" msgstr "계정" @@ -129,7 +129,7 @@ msgstr "계정 언뮤트됨" #: src/components/dialogs/MutedWords.tsx:164 #: src/view/com/modals/ListAddRemoveUsers.tsx:268 #: src/view/com/modals/UserAddRemoveLists.tsx:219 -#: src/view/screens/ProfileList.tsx:829 +#: src/view/screens/ProfileList.tsx:823 msgid "Add" msgstr "추가" @@ -137,13 +137,13 @@ msgstr "추가" msgid "Add a content warning" msgstr "콘텐츠 경고 추가" -#: src/view/screens/ProfileList.tsx:819 +#: src/view/screens/ProfileList.tsx:813 msgid "Add a user to this list" msgstr "이 리스트에 사용자 추가" #: src/components/dialogs/SwitchAccount.tsx:56 -#: src/view/screens/Settings/index.tsx:398 -#: src/view/screens/Settings/index.tsx:407 +#: src/view/screens/Settings/index.tsx:413 +#: src/view/screens/Settings/index.tsx:422 msgid "Add account" msgstr "계정 추가" @@ -204,7 +204,7 @@ msgid "Adult content is disabled." msgstr "성인 콘텐츠가 비활성화되어 있습니다." #: src/screens/Moderation/index.tsx:375 -#: src/view/screens/Settings/index.tsx:636 +#: src/view/screens/Settings/index.tsx:652 msgid "Advanced" msgstr "고급" @@ -217,7 +217,7 @@ msgstr "저장한 모든 피드를 한 곳에서 확인하세요." msgid "Already have a code?" msgstr "이미 코드가 있나요?" -#: src/screens/Login/ChooseAccountForm.tsx:39 +#: src/screens/Login/ChooseAccountForm.tsx:49 msgid "Already signed in as @{0}" msgstr "이미 @{0}(으)로 로그인했습니다" @@ -247,6 +247,10 @@ msgstr "이전 주소인 {0}(으)로 이메일을 보냈습니다. 이 이메일 msgid "An error occured" msgstr "오류 발생" +#: src/components/dms/MessageMenu.tsx:132 +msgid "An error occurred while trying to delete the message. Please try again." +msgstr "메시지를 삭제하는 동안 오류가 발생했습니다. 다시 시도해 주세요." + #: src/lib/moderation/useReportOptions.ts:26 msgid "An issue not included in these options" msgstr "어떤 옵션에도 포함되지 않는 문제" @@ -293,13 +297,13 @@ msgstr "앱 비밀번호 이름에는 문자, 숫자, 공백, 대시, 밑줄만 msgid "App Password names must be at least 4 characters long." msgstr "앱 비밀번호 이름은 4자 이상이어야 합니다." -#: src/view/screens/Settings/index.tsx:647 +#: src/view/screens/Settings/index.tsx:663 msgid "App password settings" msgstr "앱 비밀번호 설정" #: src/Navigation.tsx:258 #: src/view/screens/AppPasswords.tsx:189 -#: src/view/screens/Settings/index.tsx:656 +#: src/view/screens/Settings/index.tsx:672 msgid "App Passwords" msgstr "앱 비밀번호" @@ -316,7 +320,7 @@ msgstr "\"{0}\" 라벨 이의신청" msgid "Appeal submitted." msgstr "이의신청 제출함" -#: src/view/screens/Settings/index.tsx:414 +#: src/view/screens/Settings/index.tsx:430 msgid "Appearance" msgstr "모양" @@ -324,6 +328,14 @@ msgstr "모양" msgid "Are you sure you want to delete the app password \"{name}\"?" msgstr "앱 비밀번호 \"{name}\"을(를) 삭제하시겠습니까?" +#: src/components/dms/MessageMenu.tsx:121 +msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for other participants." +msgstr "정말 이 메시지를 삭제하시겠습니까? 나에게 보이는 메시지는 삭제되지만 상대방에게는 삭제되지 않습니다." + +#: src/components/dms/ConvoMenu.tsx:173 +msgid "Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants." +msgstr "정말 이 대화를 종료하시겠습니까? 나에게 보이는 메시지는 삭제되지만 상대방에게는 삭제되지 않습니다." + #: src/view/com/feeds/FeedSourceCard.tsx:280 msgid "Are you sure you want to remove {0} from your feeds?" msgstr "피드에서 {0}을(를) 제거하시겠습니까?" @@ -354,16 +366,17 @@ msgstr "3자 이상" #: src/components/moderation/LabelsOnMeDialog.tsx:247 #: src/components/moderation/LabelsOnMeDialog.tsx:248 -#: src/screens/Login/ChooseAccountForm.tsx:73 -#: src/screens/Login/ChooseAccountForm.tsx:78 +#: src/screens/Login/ChooseAccountForm.tsx:98 +#: src/screens/Login/ChooseAccountForm.tsx:103 #: src/screens/Login/ForgotPasswordForm.tsx:129 #: src/screens/Login/ForgotPasswordForm.tsx:135 #: src/screens/Login/LoginForm.tsx:269 #: src/screens/Login/LoginForm.tsx:275 #: src/screens/Login/SetNewPasswordForm.tsx:160 #: src/screens/Login/SetNewPasswordForm.tsx:166 +#: src/screens/Messages/Conversation/index.tsx:117 #: src/screens/Profile/Header/Shell.tsx:99 -#: src/screens/Signup/index.tsx:182 +#: src/screens/Signup/index.tsx:191 #: src/view/com/util/ViewHeader.tsx:89 msgid "Back" msgstr "뒤로" @@ -372,7 +385,7 @@ msgstr "뒤로" msgid "Based on your interest in {interestsText}" msgstr "{interestsText}에 대한 관심사 기반" -#: src/view/screens/Settings/index.tsx:471 +#: src/view/screens/Settings/index.tsx:487 msgid "Basics" msgstr "기본" @@ -380,7 +393,7 @@ msgstr "기본" msgid "Birthday" msgstr "생년월일" -#: src/view/screens/Settings/index.tsx:355 +#: src/view/screens/Settings/index.tsx:368 msgid "Birthday:" msgstr "생년월일:" @@ -389,6 +402,11 @@ msgstr "생년월일:" msgid "Block" msgstr "차단" +#: src/components/dms/ConvoMenu.tsx:135 +#: src/components/dms/ConvoMenu.tsx:139 +msgid "Block account" +msgstr "계정 차단" + #: src/view/com/profile/ProfileMenu.tsx:300 #: src/view/com/profile/ProfileMenu.tsx:307 msgid "Block Account" @@ -398,16 +416,15 @@ msgstr "계정 차단" msgid "Block Account?" msgstr "계정을 차단하시겠습니까?" -#: src/view/screens/ProfileList.tsx:532 +#: src/view/screens/ProfileList.tsx:526 msgid "Block accounts" msgstr "계정 차단" -#: src/view/screens/ProfileList.tsx:480 -#: src/view/screens/ProfileList.tsx:636 +#: src/view/screens/ProfileList.tsx:630 msgid "Block list" msgstr "리스트 차단" -#: src/view/screens/ProfileList.tsx:631 +#: src/view/screens/ProfileList.tsx:625 msgid "Block these accounts?" msgstr "이 계정들을 차단하시겠습니까?" @@ -433,7 +450,7 @@ msgstr "차단한 계정은 내 스레드에 답글을 달거나 나를 멘션 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." msgstr "차단한 계정은 내 스레드에 답글을 달거나 나를 멘션하거나 기타 다른 방식으로 나와 상호작용할 수 없습니다. 차단한 계정의 콘텐츠를 볼 수 없으며 해당 계정도 내 콘텐츠를 볼 수 없게 됩니다." -#: src/view/com/post-thread/PostThread.tsx:313 +#: src/view/com/post-thread/PostThread.tsx:316 msgid "Blocked post." msgstr "차단된 게시물." @@ -441,7 +458,7 @@ msgstr "차단된 게시물." msgid "Blocking does not prevent this labeler from placing labels on your account." msgstr "차단하더라도 이 라벨러가 내 계정에 라벨을 붙이는 것을 막지는 못합니다." -#: src/view/screens/ProfileList.tsx:633 +#: src/view/screens/ProfileList.tsx:627 msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "차단 목록은 공개됩니다. 차단한 계정은 내 스레드에 답글을 달거나 나를 멘션하거나 기타 다른 방식으로 나와 상호작용할 수 없습니다." @@ -449,7 +466,7 @@ msgstr "차단 목록은 공개됩니다. 차단한 계정은 내 스레드에 msgid "Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you." msgstr "차단하더라도 내 계정에 라벨이 붙는 것은 막지 못하지만, 이 계정이 내 스레드에 답글을 달거나 나와 상호작용하는 것은 중지됩니다." -#: src/view/com/auth/SplashScreen.web.tsx:149 +#: src/view/com/auth/SplashScreen.web.tsx:154 msgid "Blog" msgstr "블로그" @@ -478,7 +495,7 @@ msgstr "이미지 흐리게 및 피드에서 필터링" msgid "Books" msgstr "책" -#: src/view/com/auth/SplashScreen.web.tsx:146 +#: src/view/com/auth/SplashScreen.web.tsx:151 msgid "Business" msgstr "비즈니스" @@ -510,7 +527,7 @@ msgstr "카메라" msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." msgstr "글자, 숫자, 공백, 대시, 밑줄만 포함할 수 있습니다. 길이는 4자 이상이어야 하고 32자를 넘지 않아야 합니다." -#: src/components/Menu/index.tsx:213 +#: src/components/Menu/index.tsx:215 #: src/components/Prompt.tsx:113 #: src/components/Prompt.tsx:115 #: src/components/TagMenu/index.tsx:268 @@ -578,17 +595,17 @@ msgstr "연결된 웹사이트를 여는 것을 취소합니다" msgid "Change" msgstr "변경" -#: src/view/screens/Settings/index.tsx:349 +#: src/view/screens/Settings/index.tsx:362 msgctxt "action" msgid "Change" msgstr "변경" -#: src/view/screens/Settings/index.tsx:668 +#: src/view/screens/Settings/index.tsx:684 msgid "Change handle" msgstr "핸들 변경" #: src/view/com/modals/ChangeHandle.tsx:163 -#: src/view/screens/Settings/index.tsx:679 +#: src/view/screens/Settings/index.tsx:695 msgid "Change Handle" msgstr "핸들 변경" @@ -596,12 +613,12 @@ msgstr "핸들 변경" msgid "Change my email" msgstr "내 이메일 변경하기" -#: src/view/screens/Settings/index.tsx:713 +#: src/view/screens/Settings/index.tsx:729 msgid "Change password" msgstr "비밀번호 변경" #: src/view/com/modals/ChangePassword.tsx:143 -#: src/view/screens/Settings/index.tsx:724 +#: src/view/screens/Settings/index.tsx:740 msgid "Change Password" msgstr "비밀번호 변경" @@ -617,9 +634,18 @@ msgstr "이메일 변경" msgid "Chat" msgstr "대화" -#: src/screens/Messages/Conversation/index.tsx:26 -msgid "Chat with {chatId}" -msgstr "{chatId} 님과의 대화" +#: src/components/dms/ConvoMenu.tsx:55 +msgid "Chat muted" +msgstr "대화 뮤트됨" + +#: src/components/dms/ConvoMenu.tsx:87 +#: src/components/dms/MessageMenu.tsx:69 +msgid "Chat settings" +msgstr "대화 설정" + +#: src/components/dms/ConvoMenu.tsx:65 +msgid "Chat unmuted" +msgstr "대화 언뮤트됨" #: src/screens/Deactivated.tsx:79 #: src/screens/Deactivated.tsx:83 @@ -654,19 +680,19 @@ msgstr "기본 피드 선택" msgid "Choose your password" msgstr "비밀번호를 입력하세요" -#: src/view/screens/Settings/index.tsx:827 +#: src/view/screens/Settings/index.tsx:843 msgid "Clear all legacy storage data" msgstr "모든 레거시 스토리지 데이터 지우기" -#: src/view/screens/Settings/index.tsx:830 +#: src/view/screens/Settings/index.tsx:846 msgid "Clear all legacy storage data (restart after this)" msgstr "모든 레거시 스토리지 데이터 지우기 (이후 다시 시작)" -#: src/view/screens/Settings/index.tsx:839 +#: src/view/screens/Settings/index.tsx:855 msgid "Clear all storage data" msgstr "모든 스토리지 데이터 지우기" -#: src/view/screens/Settings/index.tsx:842 +#: src/view/screens/Settings/index.tsx:858 msgid "Clear all storage data (restart after this)" msgstr "모든 스토리지 데이터 지우기 (이후 다시 시작)" @@ -675,11 +701,11 @@ msgstr "모든 스토리지 데이터 지우기 (이후 다시 시작)" msgid "Clear search query" msgstr "검색어 지우기" -#: src/view/screens/Settings/index.tsx:828 +#: src/view/screens/Settings/index.tsx:844 msgid "Clears all legacy storage data" msgstr "모든 레거시 스토리지 데이터를 지웁니다" -#: src/view/screens/Settings/index.tsx:840 +#: src/view/screens/Settings/index.tsx:856 msgid "Clears all storage data" msgstr "모든 스토리지 데이터를 지웁니다" @@ -734,7 +760,7 @@ msgstr "이미지 뷰어 닫기" msgid "Close navigation footer" msgstr "탐색 푸터 닫기" -#: src/components/Menu/index.tsx:207 +#: src/components/Menu/index.tsx:209 #: src/components/TagMenu/index.tsx:262 msgid "Close this dialog" msgstr "이 대화 상자 닫기" @@ -776,7 +802,7 @@ msgstr "커뮤니티 가이드라인" msgid "Complete onboarding and start using your account" msgstr "온보딩 완료 후 계정 사용 시작" -#: src/screens/Signup/index.tsx:157 +#: src/screens/Signup/index.tsx:166 msgid "Complete the challenge" msgstr "챌린지 완료하기" @@ -847,7 +873,7 @@ msgstr "인증 코드" msgid "Connecting..." msgstr "연결 중…" -#: src/screens/Signup/index.tsx:227 +#: src/screens/Signup/index.tsx:236 msgid "Contact support" msgstr "지원에 연락하기" @@ -896,7 +922,7 @@ msgstr "컨텍스트 메뉴 배경을 클릭하여 메뉴를 닫습니다." msgid "Continue" msgstr "계속" -#: src/components/AccountList.tsx:108 +#: src/components/AccountList.tsx:113 msgid "Continue as {0} (currently signed in)" msgstr "{0}(으)로 계속하기 (현재 로그인)" @@ -904,7 +930,7 @@ msgstr "{0}(으)로 계속하기 (현재 로그인)" #: src/screens/Onboarding/StepInterests/index.tsx:250 #: src/screens/Onboarding/StepModeration/index.tsx:100 #: src/screens/Onboarding/StepTopicalFeeds.tsx:115 -#: src/screens/Signup/index.tsx:202 +#: src/screens/Signup/index.tsx:211 msgid "Continue to next step" msgstr "다음 단계로 계속하기" @@ -925,10 +951,11 @@ msgstr "요리" msgid "Copied" msgstr "복사됨" -#: src/view/screens/Settings/index.tsx:243 +#: src/view/screens/Settings/index.tsx:260 msgid "Copied build version to clipboard" msgstr "빌드 버전 클립보드에 복사됨" +#: src/components/dms/MessageMenu.tsx:47 #: src/view/com/modals/AddAppPasswords.tsx:77 #: src/view/com/modals/ChangeHandle.tsx:327 #: src/view/com/modals/InviteCodes.tsx:153 @@ -966,6 +993,11 @@ msgstr "리스트 링크 복사" msgid "Copy link to post" msgstr "게시물 링크 복사" +#: src/components/dms/MessageMenu.tsx:89 +#: src/components/dms/MessageMenu.tsx:91 +msgid "Copy message text" +msgstr "메시지 텍스트 복사" + #: src/view/com/util/forms/PostDropdownBtn.tsx:230 #: src/view/com/util/forms/PostDropdownBtn.tsx:232 msgid "Copy post text" @@ -976,24 +1008,40 @@ msgstr "게시물 텍스트 복사" msgid "Copyright Policy" msgstr "저작권 정책" +#: src/components/dms/ConvoMenu.tsx:79 +msgid "Could not leave chat" +msgstr "대화를 종료할 수 없습니다" + #: src/view/screens/ProfileFeed.tsx:103 msgid "Could not load feed" msgstr "피드를 불러올 수 없습니다" -#: src/view/screens/ProfileList.tsx:909 +#: src/view/screens/ProfileList.tsx:903 msgid "Could not load list" msgstr "리스트를 불러올 수 없습니다" +#: src/components/dms/NewChat.tsx:241 +msgid "Could not load profiles. Please try again later." +msgstr "프로필을 불러올 수 없습니다. 나중에 다시 시도하세요." + +#: src/components/dms/ConvoMenu.tsx:58 +msgid "Could not mute chat" +msgstr "대화를 뮤트할 수 없습니다" + +#: src/components/dms/ConvoMenu.tsx:68 +msgid "Could not unmute chat" +msgstr "대화를 언뮤트할 수 없습니다" + #: src/view/com/auth/SplashScreen.tsx:57 -#: src/view/com/auth/SplashScreen.web.tsx:101 +#: src/view/com/auth/SplashScreen.web.tsx:106 msgid "Create a new account" msgstr "새 계정 만들기" -#: src/view/screens/Settings/index.tsx:399 +#: src/view/screens/Settings/index.tsx:414 msgid "Create a new Bluesky account" msgstr "새 Bluesky 계정을 만듭니다" -#: src/screens/Signup/index.tsx:132 +#: src/screens/Signup/index.tsx:141 msgid "Create Account" msgstr "계정 만들기" @@ -1007,7 +1055,7 @@ msgid "Create App Password" msgstr "앱 비밀번호 만들기" #: src/view/com/auth/SplashScreen.tsx:48 -#: src/view/com/auth/SplashScreen.web.tsx:92 +#: src/view/com/auth/SplashScreen.web.tsx:97 msgid "Create new account" msgstr "새 계정 만들기" @@ -1041,8 +1089,8 @@ msgstr "커뮤니티에서 구축한 맞춤 피드는 새로운 경험을 제공 msgid "Customize media from external sites." msgstr "외부 사이트 미디어를 사용자 지정합니다." -#: src/view/screens/Settings/index.tsx:433 -#: src/view/screens/Settings/index.tsx:459 +#: src/view/screens/Settings/index.tsx:449 +#: src/view/screens/Settings/index.tsx:475 msgid "Dark" msgstr "어두움" @@ -1050,7 +1098,7 @@ msgstr "어두움" msgid "Dark mode" msgstr "어두운 모드" -#: src/view/screens/Settings/index.tsx:446 +#: src/view/screens/Settings/index.tsx:462 msgid "Dark Theme" msgstr "어두운 테마" @@ -1058,7 +1106,7 @@ msgstr "어두운 테마" msgid "Date of birth" msgstr "생년월일" -#: src/view/screens/Settings/index.tsx:800 +#: src/view/screens/Settings/index.tsx:816 msgid "Debug Moderation" msgstr "검토 디버그" @@ -1066,13 +1114,14 @@ msgstr "검토 디버그" msgid "Debug panel" msgstr "디버그 패널" +#: src/components/dms/MessageMenu.tsx:123 #: src/view/com/util/forms/PostDropdownBtn.tsx:345 #: src/view/screens/AppPasswords.tsx:268 -#: src/view/screens/ProfileList.tsx:615 +#: src/view/screens/ProfileList.tsx:609 msgid "Delete" msgstr "삭제" -#: src/view/screens/Settings/index.tsx:755 +#: src/view/screens/Settings/index.tsx:771 msgid "Delete account" msgstr "계정 삭제" @@ -1088,15 +1137,27 @@ msgstr "앱 비밀번호 삭제" msgid "Delete app password?" msgstr "앱 비밀번호를 삭제하시겠습니까?" +#: src/components/dms/MessageMenu.tsx:101 +msgid "Delete for me" +msgstr "내게서 삭제" + #: src/view/screens/ProfileList.tsx:417 msgid "Delete List" msgstr "리스트 삭제" +#: src/components/dms/MessageMenu.tsx:119 +msgid "Delete message" +msgstr "메시지 삭제" + +#: src/components/dms/MessageMenu.tsx:99 +msgid "Delete message for me" +msgstr "내게 보이는 메시지 삭제" + #: src/view/com/modals/DeleteAccount.tsx:223 msgid "Delete my account" msgstr "내 계정 삭제" -#: src/view/screens/Settings/index.tsx:767 +#: src/view/screens/Settings/index.tsx:783 msgid "Delete My Account…" msgstr "내 계정 삭제…" @@ -1105,7 +1166,7 @@ msgstr "내 계정 삭제…" msgid "Delete post" msgstr "게시물 삭제" -#: src/view/screens/ProfileList.tsx:610 +#: src/view/screens/ProfileList.tsx:604 msgid "Delete this list?" msgstr "이 리스트를 삭제하시겠습니까?" @@ -1117,7 +1178,7 @@ msgstr "이 게시물을 삭제하시겠습니까?" msgid "Deleted" msgstr "삭제됨" -#: src/view/com/post-thread/PostThread.tsx:305 +#: src/view/com/post-thread/PostThread.tsx:308 msgid "Deleted post." msgstr "삭제된 게시물." @@ -1132,7 +1193,7 @@ msgstr "설명" msgid "Did you want to say anything?" msgstr "하고 싶은 말이 있나요?" -#: src/view/screens/Settings/index.tsx:452 +#: src/view/screens/Settings/index.tsx:468 msgid "Dim" msgstr "어둑함" @@ -1335,7 +1396,7 @@ msgstr "프로필 편집" msgid "Edit Profile" msgstr "프로필 편집" -#: src/view/com/home/HomeHeaderLayout.web.tsx:66 +#: src/view/com/home/HomeHeaderLayout.web.tsx:76 #: src/view/screens/Feeds.tsx:380 msgid "Edit Saved Feeds" msgstr "저장된 피드 편집" @@ -1382,7 +1443,7 @@ msgstr "이메일 변경됨" msgid "Email verified" msgstr "이메일 확인됨" -#: src/view/screens/Settings/index.tsx:327 +#: src/view/screens/Settings/index.tsx:340 msgid "Email:" msgstr "이메일:" @@ -1492,7 +1553,7 @@ msgstr "아래에 새 이메일 주소를 입력하세요." msgid "Enter your username and password" msgstr "사용자 이름 및 비밀번호 입력" -#: src/screens/Signup/StepCaptcha/index.tsx:49 +#: src/screens/Signup/StepCaptcha/index.tsx:51 msgid "Error receiving captcha response." msgstr "캡차 응답을 수신하는 동안 오류가 발생했습니다." @@ -1546,12 +1607,12 @@ msgstr "노골적이거나 불쾌감을 줄 수 있는 미디어." msgid "Explicit sexual images." msgstr "노골적인 성적 이미지." -#: src/view/screens/Settings/index.tsx:736 +#: src/view/screens/Settings/index.tsx:752 msgid "Export my data" msgstr "내 데이터 내보내기" #: src/view/screens/Settings/ExportCarDialog.tsx:45 -#: src/view/screens/Settings/index.tsx:747 +#: src/view/screens/Settings/index.tsx:763 msgid "Export My Data" msgstr "내 데이터 내보내기" @@ -1567,11 +1628,11 @@ msgstr "외부 미디어는 웹사이트가 나와 내 기기에 대한 정보 #: src/Navigation.tsx:282 #: src/view/screens/PreferencesExternalEmbeds.tsx:53 -#: src/view/screens/Settings/index.tsx:629 +#: src/view/screens/Settings/index.tsx:645 msgid "External Media Preferences" msgstr "외부 미디어 설정" -#: src/view/screens/Settings/index.tsx:620 +#: src/view/screens/Settings/index.tsx:636 msgid "External media settings" msgstr "외부 미디어 설정" @@ -1584,6 +1645,10 @@ msgstr "앱 비밀번호를 만들지 못했습니다." msgid "Failed to create the list. Check your internet connection and try again." msgstr "리스트를 만들지 못했습니다. 인터넷 연결을 확인한 후 다시 시도하세요." +#: src/components/dms/MessageMenu.tsx:130 +msgid "Failed to delete message" +msgstr "메시지를 삭제하지 못했습니다" + #: src/view/com/util/forms/PostDropdownBtn.tsx:131 msgid "Failed to delete post, please try again" msgstr "게시물을 삭제하지 못했습니다. 다시 시도해 주세요" @@ -1592,6 +1657,10 @@ msgstr "게시물을 삭제하지 못했습니다. 다시 시도해 주세요" msgid "Failed to load GIFs" msgstr "GIF 불러오기 실패" +#: src/screens/Messages/Conversation/MessageListError.tsx:21 +msgid "Failed to load past messages." +msgstr "지난 메시지를 불러오지 못했습니다." + #: src/view/com/lightbox/Lightbox.tsx:83 msgid "Failed to save image: {0}" msgstr "이미지를 저장하지 못함: {0}" @@ -1608,8 +1677,8 @@ msgstr "{0} 님의 피드" msgid "Feed offline" msgstr "피드 오프라인" -#: src/view/shell/desktop/RightNav.tsx:61 -#: src/view/shell/Drawer.tsx:312 +#: src/view/shell/desktop/RightNav.tsx:65 +#: src/view/shell/Drawer.tsx:325 msgid "Feedback" msgstr "피드백" @@ -1619,8 +1688,8 @@ msgstr "피드백" #: src/view/screens/Profile.tsx:197 #: src/view/shell/bottom-bar/BottomBar.tsx:212 #: src/view/shell/desktop/LeftNav.tsx:379 -#: src/view/shell/Drawer.tsx:477 -#: src/view/shell/Drawer.tsx:478 +#: src/view/shell/Drawer.tsx:490 +#: src/view/shell/Drawer.tsx:491 msgid "Feeds" msgstr "피드" @@ -1746,15 +1815,15 @@ msgstr "팔로우 중" msgid "Following {0}" msgstr "{0} 님을 팔로우했습니다" -#: src/view/screens/Settings/index.tsx:548 +#: src/view/screens/Settings/index.tsx:564 msgid "Following feed preferences" msgstr "팔로우 중 피드 설정" #: src/Navigation.tsx:269 -#: src/view/com/home/HomeHeaderLayout.web.tsx:54 +#: src/view/com/home/HomeHeaderLayout.web.tsx:64 #: src/view/com/home/HomeHeaderLayoutMobile.tsx:87 #: src/view/screens/PreferencesFollowingFeed.tsx:104 -#: src/view/screens/Settings/index.tsx:557 +#: src/view/screens/Settings/index.tsx:573 msgid "Following Feed Preferences" msgstr "팔로우 중 피드 설정" @@ -1823,17 +1892,17 @@ msgstr "명백한 법률 또는 서비스 이용약관 위반 행위" #: src/view/com/auth/LoggedOut.tsx:83 #: src/view/screens/NotFound.tsx:55 #: src/view/screens/ProfileFeed.tsx:112 -#: src/view/screens/ProfileList.tsx:918 +#: src/view/screens/ProfileList.tsx:912 #: src/view/shell/desktop/LeftNav.tsx:111 msgid "Go back" msgstr "뒤로" -#: src/components/Error.tsx:100 +#: src/components/Error.tsx:103 #: src/screens/Profile/ErrorState.tsx:62 #: src/screens/Profile/ErrorState.tsx:66 #: src/view/screens/NotFound.tsx:54 #: src/view/screens/ProfileFeed.tsx:117 -#: src/view/screens/ProfileList.tsx:923 +#: src/view/screens/ProfileList.tsx:917 msgid "Go Back" msgstr "뒤로" @@ -1841,7 +1910,7 @@ msgstr "뒤로" #: src/components/ReportDialog/SubmitView.tsx:104 #: src/screens/Onboarding/Layout.tsx:102 #: src/screens/Onboarding/Layout.tsx:191 -#: src/screens/Signup/index.tsx:176 +#: src/screens/Signup/index.tsx:185 msgid "Go back to previous step" msgstr "이전 단계로 돌아가기" @@ -1853,16 +1922,19 @@ msgstr "홈으로 이동" msgid "Go Home" msgstr "홈으로 이동" -#: src/view/screens/Search/Search.tsx:780 -#: src/view/shell/desktop/Search.tsx:250 -#~ msgid "Go to @{queryMaybeHandle}" -#~ msgstr "@{queryMaybeHandle}(으)로 이동" - #: src/screens/Login/ForgotPasswordForm.tsx:172 #: src/view/com/modals/ChangePassword.tsx:169 msgid "Go to next" msgstr "다음" +#: src/components/dms/ConvoMenu.tsx:114 +msgid "Go to profile" +msgstr "프로필로 가기" + +#: src/components/dms/ConvoMenu.tsx:111 +msgid "Go to user's profile" +msgstr "사용자의 프로필로 가기" + #: src/lib/moderation/useGlobalLabelStrings.ts:46 msgid "Graphic Media" msgstr "그래픽 미디어" @@ -1887,12 +1959,12 @@ msgstr "해시태그" msgid "Hashtag: #{tag}" msgstr "해시태그: #{tag}" -#: src/screens/Signup/index.tsx:223 +#: src/screens/Signup/index.tsx:232 msgid "Having trouble?" msgstr "문제가 있나요?" -#: src/view/shell/desktop/RightNav.tsx:90 -#: src/view/shell/Drawer.tsx:322 +#: src/view/shell/desktop/RightNav.tsx:94 +#: src/view/shell/Drawer.tsx:335 msgid "Help" msgstr "도움말" @@ -1979,8 +2051,8 @@ msgstr "검토 서비스를 불러올 수 없습니다." #: src/Navigation.tsx:497 #: src/view/shell/bottom-bar/BottomBar.tsx:168 #: src/view/shell/desktop/LeftNav.tsx:314 -#: src/view/shell/Drawer.tsx:399 -#: src/view/shell/Drawer.tsx:400 +#: src/view/shell/Drawer.tsx:412 +#: src/view/shell/Drawer.tsx:413 msgid "Home" msgstr "홈" @@ -2025,7 +2097,7 @@ msgstr "아무것도 선택하지 않으면 모든 연령대에 적합하다는 msgid "If you are not yet an adult according to the laws of your country, your parent or legal guardian must read these Terms on your behalf." msgstr "해당 국가의 법률에 따라 아직 성인이 아닌 경우, 부모 또는 법적 보호자가 대신 이 약관을 읽어야 합니다." -#: src/view/screens/ProfileList.tsx:612 +#: src/view/screens/ProfileList.tsx:606 msgid "If you delete this list, you won't be able to recover it." msgstr "이 리스트를 삭제하면 다시 복구할 수 없습니다." @@ -2118,7 +2190,7 @@ msgstr "친구 초대하기" msgid "Invite code" msgstr "초대 코드" -#: src/screens/Signup/state.ts:278 +#: src/screens/Signup/state.ts:282 msgid "Invite code not accepted. Check that you input it correctly and try again." msgstr "초대 코드가 올바르지 않습니다. 코드를 올바르게 입력했는지 확인한 후 다시 시도하세요." @@ -2134,7 +2206,7 @@ msgstr "초대 코드: 1개 사용 가능" msgid "It shows posts from the people you follow as they happen." msgstr "내가 팔로우하는 사람들의 게시물이 올라오는 대로 표시됩니다." -#: src/view/com/auth/SplashScreen.web.tsx:152 +#: src/view/com/auth/SplashScreen.web.tsx:157 msgid "Jobs" msgstr "채용" @@ -2178,7 +2250,7 @@ msgstr "내 콘텐츠의 라벨" msgid "Language selection" msgstr "언어 선택" -#: src/view/screens/Settings/index.tsx:505 +#: src/view/screens/Settings/index.tsx:521 msgid "Language settings" msgstr "언어 설정" @@ -2187,7 +2259,7 @@ msgstr "언어 설정" msgid "Language Settings" msgstr "언어 설정" -#: src/view/screens/Settings/index.tsx:514 +#: src/view/screens/Settings/index.tsx:530 msgid "Languages" msgstr "언어" @@ -2218,6 +2290,16 @@ msgstr "Bluesky에서 공개되는 항목에 대해 자세히 알아보세요." msgid "Learn more." msgstr "더 알아보기" +#: src/components/dms/ConvoMenu.tsx:175 +msgid "Leave" +msgstr "종료" + +#: src/components/dms/ConvoMenu.tsx:158 +#: src/components/dms/ConvoMenu.tsx:161 +#: src/components/dms/ConvoMenu.tsx:171 +msgid "Leave conversation" +msgstr "대화 종료" + #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 msgid "Leave them all unchecked to see any language." msgstr "모든 언어를 보려면 모두 선택하지 않은 상태로 두세요." @@ -2230,7 +2312,7 @@ msgstr "Bluesky 떠나기" msgid "left to go." msgstr "명 남았습니다." -#: src/view/screens/Settings/index.tsx:292 +#: src/view/screens/Settings/index.tsx:305 msgid "Legacy storage cleared, you need to restart the app now." msgstr "레거시 스토리지가 지워졌으며 지금 앱을 다시 시작해야 합니다." @@ -2243,7 +2325,7 @@ msgstr "비밀번호를 재설정해 봅시다!" msgid "Let's go!" msgstr "출발!" -#: src/view/screens/Settings/index.tsx:427 +#: src/view/screens/Settings/index.tsx:443 msgid "Light" msgstr "밝음" @@ -2338,8 +2420,8 @@ msgstr "리스트 언뮤트됨" #: src/view/screens/Profile.tsx:192 #: src/view/screens/Profile.tsx:198 #: src/view/shell/desktop/LeftNav.tsx:397 -#: src/view/shell/Drawer.tsx:493 -#: src/view/shell/Drawer.tsx:494 +#: src/view/shell/Drawer.tsx:506 +#: src/view/shell/Drawer.tsx:507 msgid "Lists" msgstr "리스트" @@ -2350,7 +2432,7 @@ msgstr "새 알림 불러오기" #: src/screens/Profile/Sections/Feed.tsx:86 #: src/view/com/feeds/FeedPage.tsx:134 #: src/view/screens/ProfileFeed.tsx:507 -#: src/view/screens/ProfileList.tsx:697 +#: src/view/screens/ProfileList.tsx:691 msgid "Load new posts" msgstr "새 게시물 불러오기" @@ -2373,7 +2455,7 @@ msgstr "로그아웃" msgid "Logged-out visibility" msgstr "로그아웃 표시" -#: src/components/AccountList.tsx:54 +#: src/components/AccountList.tsx:58 msgid "Login to account that is not listed" msgstr "목록에 없는 계정으로 로그인" @@ -2411,16 +2493,28 @@ msgstr "멘션한 사용자" msgid "Menu" msgstr "메뉴" +#: src/components/dms/MessageMenu.tsx:56 +#: src/screens/Messages/List/index.tsx:245 +msgid "Message deleted" +msgstr "메시지 삭제됨" + #: src/view/com/posts/FeedErrorMessage.tsx:192 msgid "Message from server: {0}" msgstr "서버에서 보낸 메시지: {0}" -#: src/screens/Messages/List/index.tsx:34 +#: src/screens/Messages/Conversation/MessageInput.tsx:79 +msgid "Message input field" +msgstr "메시지 입력 필드" + +#: src/screens/Messages/List/index.tsx:62 +#: src/screens/Messages/List/index.tsx:374 msgid "Message settings" msgstr "메시지 설정" #: src/Navigation.tsx:517 -#: src/screens/Messages/List/index.tsx:103 +#: src/screens/Messages/List/index.tsx:163 +#: src/screens/Messages/List/index.tsx:190 +#: src/screens/Messages/List/index.tsx:370 #: src/view/shell/bottom-bar/BottomBar.tsx:261 #: src/view/shell/desktop/LeftNav.tsx:360 msgid "Messages" @@ -2436,7 +2530,7 @@ msgstr "오해의 소지가 있는 계정" #: src/Navigation.tsx:126 #: src/screens/Moderation/index.tsx:104 -#: src/view/screens/Settings/index.tsx:536 +#: src/view/screens/Settings/index.tsx:552 msgid "Moderation" msgstr "검토" @@ -2449,13 +2543,13 @@ msgstr "검토 세부 정보" msgid "Moderation list by {0}" msgstr "{0} 님의 검토 리스트" -#: src/view/screens/ProfileList.tsx:791 +#: src/view/screens/ProfileList.tsx:785 msgid "Moderation list by <0/>" msgstr "<0/> 님의 검토 리스트" #: src/view/com/lists/ListCard.tsx:91 #: src/view/com/modals/UserAddRemoveLists.tsx:204 -#: src/view/screens/ProfileList.tsx:789 +#: src/view/screens/ProfileList.tsx:783 msgid "Moderation list by you" msgstr "내 검토 리스트" @@ -2476,7 +2570,7 @@ msgstr "검토 리스트" msgid "Moderation Lists" msgstr "검토 리스트" -#: src/view/screens/Settings/index.tsx:530 +#: src/view/screens/Settings/index.tsx:546 msgid "Moderation settings" msgstr "검토 설정" @@ -2501,7 +2595,7 @@ msgstr "더 보기" msgid "More feeds" msgstr "피드 더 보기" -#: src/view/screens/ProfileList.tsx:601 +#: src/view/screens/ProfileList.tsx:595 msgid "More options" msgstr "옵션 더 보기" @@ -2522,7 +2616,7 @@ msgstr "{truncatedTag} 뮤트" msgid "Mute Account" msgstr "계정 뮤트" -#: src/view/screens/ProfileList.tsx:520 +#: src/view/screens/ProfileList.tsx:514 msgid "Mute accounts" msgstr "계정 뮤트" @@ -2538,12 +2632,16 @@ msgstr "태그에서만 뮤트" msgid "Mute in text & tags" msgstr "글 및 태그에서 뮤트" -#: src/view/screens/ProfileList.tsx:463 -#: src/view/screens/ProfileList.tsx:626 +#: src/view/screens/ProfileList.tsx:620 msgid "Mute list" msgstr "리스트 뮤트" -#: src/view/screens/ProfileList.tsx:621 +#: src/components/dms/ConvoMenu.tsx:119 +#: src/components/dms/ConvoMenu.tsx:125 +msgid "Mute notifications" +msgstr "알림 뮤트" + +#: src/view/screens/ProfileList.tsx:615 msgid "Mute these accounts?" msgstr "이 계정들을 뮤트하시겠습니까?" @@ -2590,7 +2688,7 @@ msgstr "\"{0}\" 님이 뮤트함" msgid "Muted words & tags" msgstr "뮤트한 단어 및 태그" -#: src/view/screens/ProfileList.tsx:623 +#: src/view/screens/ProfileList.tsx:617 msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." msgstr "뮤트 목록은 비공개입니다. 뮤트한 계정은 나와 상호작용할 수 있지만 해당 계정의 게시물을 보거나 해당 계정으로부터 알림을 받을 수 없습니다." @@ -2607,11 +2705,11 @@ msgstr "내 피드" msgid "My Profile" msgstr "내 프로필" -#: src/view/screens/Settings/index.tsx:591 +#: src/view/screens/Settings/index.tsx:607 msgid "My saved feeds" msgstr "내 저장된 피드" -#: src/view/screens/Settings/index.tsx:597 +#: src/view/screens/Settings/index.tsx:613 msgid "My Saved Feeds" msgstr "내 저장된 피드" @@ -2640,7 +2738,7 @@ msgstr "자연" msgid "Navigates to the next screen" msgstr "다음 화면으로 이동합니다" -#: src/view/shell/Drawer.tsx:70 +#: src/view/shell/Drawer.tsx:71 msgid "Navigates to your profile" msgstr "내 프로필로 이동합니다" @@ -2665,6 +2763,12 @@ msgstr "새로 만들기" msgid "New" msgstr "새로 만들기" +#: src/components/dms/NewChat.tsx:60 +#: src/screens/Messages/List/index.tsx:384 +#: src/screens/Messages/List/index.tsx:392 +msgid "New chat" +msgstr "새 대화" + #: src/view/com/modals/CreateOrEditList.tsx:255 msgid "New Moderation List" msgstr "새 검토 리스트" @@ -2715,7 +2819,7 @@ msgstr "뉴스" #: src/screens/Login/LoginForm.tsx:309 #: src/screens/Login/SetNewPasswordForm.tsx:174 #: src/screens/Login/SetNewPasswordForm.tsx:180 -#: src/screens/Signup/index.tsx:209 +#: src/screens/Signup/index.tsx:218 #: src/view/com/modals/ChangePassword.tsx:255 #: src/view/com/modals/ChangePassword.tsx:257 msgid "Next" @@ -2735,7 +2839,7 @@ msgid "No" msgstr "아니요" #: src/view/screens/ProfileFeed.tsx:574 -#: src/view/screens/ProfileList.tsx:771 +#: src/view/screens/ProfileList.tsx:765 msgid "No description" msgstr "설명 없음" @@ -2755,7 +2859,12 @@ msgstr "더 이상 {0} 님을 팔로우하지 않음" msgid "No longer than 253 characters" msgstr "253자를 초과하지 않음" -#: src/view/com/notifications/Feed.tsx:109 +#: src/screens/Messages/List/index.tsx:174 +#: src/screens/Messages/List/index.tsx:234 +msgid "No messages yet" +msgstr "아직 메시지가 없습니다" + +#: src/view/com/notifications/Feed.tsx:110 msgid "No notifications yet!" msgstr "아직 알림이 없습니다." @@ -2764,7 +2873,7 @@ msgstr "아직 알림이 없습니다." msgid "No result" msgstr "결과 없음" -#: src/components/Lists.tsx:192 +#: src/components/Lists.tsx:195 msgid "No results found" msgstr "결과를 찾을 수 없음" @@ -2782,6 +2891,10 @@ msgstr "{query}에 대한 결과를 찾을 수 없습니다" msgid "No search results found for \"{search}\"." msgstr "\"{search}\"에 대한 검색 결과를 찾을 수 없습니다." +#: src/components/dms/NewChat.tsx:240 +msgid "No search results found for \"{searchText}\"." +msgstr "\"{searchText}\"에 대한 검색 결과를 찾을 수 없습니다." + #: src/components/dialogs/EmbedConsent.tsx:105 #: src/components/dialogs/EmbedConsent.tsx:112 msgid "No thanks" @@ -2829,11 +2942,15 @@ msgstr "참고: Bluesky는 개방형 공개 네트워크입니다. 이 설정은 #: src/view/screens/Notifications.tsx:148 #: src/view/shell/bottom-bar/BottomBar.tsx:236 #: src/view/shell/desktop/LeftNav.tsx:351 -#: src/view/shell/Drawer.tsx:436 -#: src/view/shell/Drawer.tsx:437 +#: src/view/shell/Drawer.tsx:449 +#: src/view/shell/Drawer.tsx:450 msgid "Notifications" msgstr "알림" +#: src/components/dms/MessageItem.tsx:139 +msgid "Now" +msgstr "지금" + #: src/view/com/modals/SelfLabel.tsx:103 msgid "Nudity" msgstr "노출" @@ -2842,7 +2959,7 @@ msgstr "노출" msgid "Nudity or adult content not labeled as such" msgstr "누드 또는 성인 콘텐츠로 설정되지 않은 콘텐츠" -#: src/screens/Signup/index.tsx:145 +#: src/screens/Signup/index.tsx:154 msgid "of" msgstr "" @@ -2872,7 +2989,7 @@ msgstr "확인" msgid "Oldest replies first" msgstr "오래된 순" -#: src/view/screens/Settings/index.tsx:236 +#: src/view/screens/Settings/index.tsx:253 msgid "Onboarding reset" msgstr "온보딩 재설정" @@ -2892,7 +3009,7 @@ msgstr "문자, 숫자, 하이픈만 포함" msgid "Oops, something went wrong!" msgstr "이런, 뭔가 잘못되었습니다!" -#: src/components/Lists.tsx:177 +#: src/components/Lists.tsx:179 #: src/view/screens/AppPasswords.tsx:67 #: src/view/screens/Profile.tsx:100 msgid "Oops!" @@ -2911,7 +3028,7 @@ msgstr "이모티콘 선택기 열기" msgid "Open feed options menu" msgstr "피드 옵션 메뉴 열기" -#: src/view/screens/Settings/index.tsx:686 +#: src/view/screens/Settings/index.tsx:702 msgid "Open links with in-app browser" msgstr "링크를 인앱 브라우저로 열기" @@ -2927,12 +3044,12 @@ msgstr "내비게이션 열기" msgid "Open post options menu" msgstr "게시물 옵션 메뉴 열기" -#: src/view/screens/Settings/index.tsx:787 -#: src/view/screens/Settings/index.tsx:797 +#: src/view/screens/Settings/index.tsx:803 +#: src/view/screens/Settings/index.tsx:813 msgid "Open storybook page" msgstr "스토리북 페이지 열기" -#: src/view/screens/Settings/index.tsx:775 +#: src/view/screens/Settings/index.tsx:791 msgid "Open system log" msgstr "시스템 로그 열기" @@ -2940,7 +3057,7 @@ msgstr "시스템 로그 열기" msgid "Opens {numItems} options" msgstr "{numItems}번째 옵션을 엽니다" -#: src/view/screens/Settings/index.tsx:485 +#: src/view/screens/Settings/index.tsx:501 msgid "Opens accessibility settings" msgstr "접근성 설정을 엽니다" @@ -2960,7 +3077,7 @@ msgstr "기기에서 카메라를 엽니다" msgid "Opens composer" msgstr "답글 작성 상자를 엽니다" -#: src/view/screens/Settings/index.tsx:506 +#: src/view/screens/Settings/index.tsx:522 msgid "Opens configurable language settings" msgstr "구성 가능한 언어 설정을 엽니다" @@ -2968,17 +3085,17 @@ msgstr "구성 가능한 언어 설정을 엽니다" msgid "Opens device photo gallery" msgstr "기기의 사진 갤러리를 엽니다" -#: src/view/screens/Settings/index.tsx:621 +#: src/view/screens/Settings/index.tsx:637 msgid "Opens external embeds settings" msgstr "외부 임베드 설정을 엽니다" #: src/view/com/auth/SplashScreen.tsx:50 -#: src/view/com/auth/SplashScreen.web.tsx:94 +#: src/view/com/auth/SplashScreen.web.tsx:99 msgid "Opens flow to create a new Bluesky account" msgstr "새 Bluesky 계정을 만드는 플로를 엽니다" #: src/view/com/auth/SplashScreen.tsx:65 -#: src/view/com/auth/SplashScreen.web.tsx:109 +#: src/view/com/auth/SplashScreen.web.tsx:114 msgid "Opens flow to sign into your existing Bluesky account" msgstr "존재하는 Bluesky 계정에 로그인하는 플로를 엽니다" @@ -2990,23 +3107,23 @@ msgstr "GIF 선택 대화 상자를 엽니다" msgid "Opens list of invite codes" msgstr "초대 코드 목록을 엽니다" -#: src/view/screens/Settings/index.tsx:757 +#: src/view/screens/Settings/index.tsx:773 msgid "Opens modal for account deletion confirmation. Requires email code" msgstr "계정 삭제 확인을 위한 대화 상자를 엽니다. 이메일 코드가 필요합니다" -#: src/view/screens/Settings/index.tsx:715 +#: src/view/screens/Settings/index.tsx:731 msgid "Opens modal for changing your Bluesky password" msgstr "Bluesky 비밀번호 변경을 위한 대화 상자를 엽니다" -#: src/view/screens/Settings/index.tsx:670 +#: src/view/screens/Settings/index.tsx:686 msgid "Opens modal for choosing a new Bluesky handle" msgstr "새로운 Bluesky 핸들을 선택하기 위한 대화 상자를 엽니다" -#: src/view/screens/Settings/index.tsx:738 +#: src/view/screens/Settings/index.tsx:754 msgid "Opens modal for downloading your Bluesky account data (repository)" msgstr "Bluesky 계정 데이터(저장소)를 다운로드하기 위한 대화 상자를 엽니다" -#: src/view/screens/Settings/index.tsx:927 +#: src/view/screens/Settings/index.tsx:941 msgid "Opens modal for email verification" msgstr "이메일 인증을 위한 대화 상자를 엽니다" @@ -3014,7 +3131,7 @@ msgstr "이메일 인증을 위한 대화 상자를 엽니다" msgid "Opens modal for using custom domain" msgstr "사용자 지정 도메인을 사용하기 위한 대화 상자를 엽니다" -#: src/view/screens/Settings/index.tsx:531 +#: src/view/screens/Settings/index.tsx:547 msgid "Opens moderation settings" msgstr "검토 설정을 엽니다" @@ -3022,20 +3139,20 @@ msgstr "검토 설정을 엽니다" msgid "Opens password reset form" msgstr "비밀번호 재설정 양식을 엽니다" -#: src/view/com/home/HomeHeaderLayout.web.tsx:67 +#: src/view/com/home/HomeHeaderLayout.web.tsx:77 #: src/view/screens/Feeds.tsx:381 msgid "Opens screen to edit Saved Feeds" msgstr "저장된 피드를 편집할 수 있는 화면을 엽니다" -#: src/view/screens/Settings/index.tsx:592 +#: src/view/screens/Settings/index.tsx:608 msgid "Opens screen with all saved feeds" msgstr "모든 저장된 피드 화면을 엽니다" -#: src/view/screens/Settings/index.tsx:648 +#: src/view/screens/Settings/index.tsx:664 msgid "Opens the app password settings" msgstr "비밀번호 설정을 엽니다" -#: src/view/screens/Settings/index.tsx:549 +#: src/view/screens/Settings/index.tsx:565 msgid "Opens the Following feed preferences" msgstr "팔로우 중 피드 설정을 엽니다" @@ -3043,20 +3160,20 @@ msgstr "팔로우 중 피드 설정을 엽니다" msgid "Opens the linked website" msgstr "연결된 웹사이트를 엽니다" -#: src/screens/Messages/List/index.tsx:35 +#: src/screens/Messages/List/index.tsx:63 msgid "Opens the message settings page" msgstr "메시지 설정 페이지를 엽니다" -#: src/view/screens/Settings/index.tsx:788 -#: src/view/screens/Settings/index.tsx:798 +#: src/view/screens/Settings/index.tsx:804 +#: src/view/screens/Settings/index.tsx:814 msgid "Opens the storybook page" msgstr "스토리북 페이지를 엽니다" -#: src/view/screens/Settings/index.tsx:776 +#: src/view/screens/Settings/index.tsx:792 msgid "Opens the system log page" msgstr "시스템 로그 페이지를 엽니다" -#: src/view/screens/Settings/index.tsx:570 +#: src/view/screens/Settings/index.tsx:586 msgid "Opens the threads preferences" msgstr "스레드 설정을 엽니다" @@ -3076,7 +3193,7 @@ msgstr "또는 다음 옵션을 결합하세요:" msgid "Other" msgstr "기타" -#: src/components/AccountList.tsx:73 +#: src/components/AccountList.tsx:76 msgid "Other account" msgstr "다른 계정" @@ -3084,7 +3201,7 @@ msgstr "다른 계정" msgid "Other..." msgstr "기타…" -#: src/components/Lists.tsx:193 +#: src/components/Lists.tsx:196 #: src/view/screens/NotFound.tsx:45 msgid "Page not found" msgstr "페이지를 찾을 수 없음" @@ -3145,7 +3262,7 @@ msgid "Pictures meant for adults." msgstr "성인용 사진." #: src/view/screens/ProfileFeed.tsx:303 -#: src/view/screens/ProfileList.tsx:565 +#: src/view/screens/ProfileList.tsx:559 msgid "Pin to home" msgstr "홈에 고정" @@ -3186,7 +3303,7 @@ msgstr "핸들을 입력하세요." msgid "Please choose your password." msgstr "비밀번호를 입력하세요." -#: src/screens/Signup/state.ts:251 +#: src/screens/Signup/state.ts:255 msgid "Please complete the verification captcha." msgstr "인증 캡차를 완료해 주세요." @@ -3218,6 +3335,10 @@ msgstr "비밀번호도 입력해 주세요:" msgid "Please explain why you think this label was incorrectly applied by {0}" msgstr "{0} 님이 이 라벨을 잘못 적용했다고 생각하는 이유를 설명해 주세요" +#: src/lib/hooks/useAccountSwitcher.ts:48 +msgid "Please sign in as @{0}" +msgstr "@{0}(으)로 로그인하세요" + #: src/view/com/modals/VerifyEmail.tsx:110 msgid "Please Verify Your Email" msgstr "이메일 인증하기" @@ -3240,7 +3361,7 @@ msgctxt "action" msgid "Post" msgstr "게시하기" -#: src/view/com/post-thread/PostThread.tsx:292 +#: src/view/com/post-thread/PostThread.tsx:295 msgctxt "description" msgid "Post" msgstr "게시물" @@ -3310,11 +3431,17 @@ msgstr "오해의 소지가 있는 링크" msgid "Press to change hosting provider" msgstr "호스팅 제공자를 변경하려면 누릅니다" -#: src/components/Error.tsx:83 +#: src/components/Error.tsx:85 #: src/components/Lists.tsx:83 -#: src/screens/Signup/index.tsx:189 +#: src/screens/Messages/Conversation/MessageListError.tsx:42 +#: src/screens/Signup/index.tsx:198 msgid "Press to retry" -msgstr "눌러서 다시 시도하기" +msgstr "다시 시도하려면 누르기" + +#: src/screens/Messages/Conversation/MessagesList.tsx:47 +#: src/screens/Messages/Conversation/MessagesList.tsx:53 +msgid "Press to Retry" +msgstr "다시 시도하려면 누르기" #: src/view/com/lightbox/Lightbox.web.tsx:150 msgid "Previous image" @@ -3328,16 +3455,16 @@ msgstr "주 언어" msgid "Prioritize Your Follows" msgstr "내 팔로우 먼저 표시" -#: src/view/screens/Settings/index.tsx:604 -#: src/view/shell/desktop/RightNav.tsx:72 +#: src/view/screens/Settings/index.tsx:620 +#: src/view/shell/desktop/RightNav.tsx:76 msgid "Privacy" msgstr "개인정보" #: src/Navigation.tsx:238 #: src/screens/Signup/StepInfo/Policies.tsx:56 #: src/view/screens/PrivacyPolicy.tsx:29 -#: src/view/screens/Settings/index.tsx:882 -#: src/view/shell/Drawer.tsx:263 +#: src/view/screens/Settings/index.tsx:890 +#: src/view/shell/Drawer.tsx:265 msgid "Privacy Policy" msgstr "개인정보 처리방침" @@ -3352,9 +3479,9 @@ msgstr "프로필" #: src/view/shell/bottom-bar/BottomBar.tsx:303 #: src/view/shell/desktop/LeftNav.tsx:415 -#: src/view/shell/Drawer.tsx:69 -#: src/view/shell/Drawer.tsx:528 -#: src/view/shell/Drawer.tsx:529 +#: src/view/shell/Drawer.tsx:70 +#: src/view/shell/Drawer.tsx:541 +#: src/view/shell/Drawer.tsx:542 msgid "Profile" msgstr "프로필" @@ -3362,7 +3489,7 @@ msgstr "프로필" msgid "Profile updated" msgstr "프로필 업데이트됨" -#: src/view/screens/Settings/index.tsx:940 +#: src/view/screens/Settings/index.tsx:954 msgid "Protect your account by verifying your email." msgstr "이메일을 인증하여 계정을 보호하세요." @@ -3520,6 +3647,15 @@ msgctxt "description" msgid "Reply to <0><1/>" msgstr "<0><1/> 님에게 보내는 답글" +#: src/components/dms/MessageMenu.tsx:109 +msgid "Report" +msgstr "신고" + +#: src/components/dms/ConvoMenu.tsx:146 +#: src/components/dms/ConvoMenu.tsx:150 +msgid "Report account" +msgstr "계정 신고" + #: src/view/com/profile/ProfileMenu.tsx:319 #: src/view/com/profile/ProfileMenu.tsx:322 msgid "Report Account" @@ -3538,6 +3674,10 @@ msgstr "피드 신고" msgid "Report List" msgstr "리스트 신고" +#: src/components/dms/MessageMenu.tsx:107 +msgid "Report message" +msgstr "메시지 신고" + #: src/view/com/util/forms/PostDropdownBtn.tsx:316 #: src/view/com/util/forms/PostDropdownBtn.tsx:318 msgid "Report post" @@ -3616,7 +3756,7 @@ msgstr "게시하기 전 대체 텍스트 필수" #: src/view/screens/Settings/Email2FAToggle.tsx:54 msgid "Require email code to log into your account" -msgstr "계정에 로그인하려면 이메일 코드가 필요합니다" +msgstr "계정에 로그인할 때 이메일 코드 필수" #: src/screens/Signup/StepInfo/index.tsx:69 msgid "Required for this provider" @@ -3635,8 +3775,8 @@ msgstr "재설정 코드" msgid "Reset Code" msgstr "재설정 코드" -#: src/view/screens/Settings/index.tsx:817 -#: src/view/screens/Settings/index.tsx:820 +#: src/view/screens/Settings/index.tsx:833 +#: src/view/screens/Settings/index.tsx:836 msgid "Reset onboarding state" msgstr "온보딩 상태 초기화" @@ -3644,16 +3784,16 @@ msgstr "온보딩 상태 초기화" msgid "Reset password" msgstr "비밀번호 재설정" -#: src/view/screens/Settings/index.tsx:807 -#: src/view/screens/Settings/index.tsx:810 +#: src/view/screens/Settings/index.tsx:823 +#: src/view/screens/Settings/index.tsx:826 msgid "Reset preferences state" msgstr "설정 상태 초기화" -#: src/view/screens/Settings/index.tsx:818 +#: src/view/screens/Settings/index.tsx:834 msgid "Resets the onboarding state" msgstr "온보딩 상태 초기화" -#: src/view/screens/Settings/index.tsx:808 +#: src/view/screens/Settings/index.tsx:824 msgid "Resets the preferences state" msgstr "설정 상태 초기화" @@ -3666,20 +3806,25 @@ msgstr "로그인을 다시 시도합니다" msgid "Retries the last action, which errored out" msgstr "오류가 발생한 마지막 작업을 다시 시도합니다" -#: src/components/Error.tsx:88 +#: src/components/dms/MessageMenu.tsx:134 +#: src/components/Error.tsx:90 #: src/components/Lists.tsx:94 #: src/screens/Login/LoginForm.tsx:282 #: src/screens/Login/LoginForm.tsx:289 #: src/screens/Onboarding/StepInterests/index.tsx:226 #: src/screens/Onboarding/StepInterests/index.tsx:229 -#: src/screens/Signup/index.tsx:196 +#: src/screens/Signup/index.tsx:205 #: src/view/com/util/error/ErrorMessage.tsx:55 #: src/view/com/util/error/ErrorScreen.tsx:72 msgid "Retry" msgstr "다시 시도" -#: src/components/Error.tsx:95 -#: src/view/screens/ProfileList.tsx:919 +#: src/screens/Messages/Conversation/MessageListError.tsx:48 +msgid "Retry." +msgstr "다시 시도" + +#: src/components/Error.tsx:98 +#: src/view/screens/ProfileList.tsx:913 msgid "Return to previous page" msgstr "이전 페이지로 돌아갑니다" @@ -3758,10 +3903,11 @@ msgstr "이미지 자르기 설정을 저장합니다" msgid "Science" msgstr "과학" -#: src/view/screens/ProfileList.tsx:875 +#: src/view/screens/ProfileList.tsx:869 msgid "Scroll to top" msgstr "맨 위로 스크롤" +#: src/components/dms/NewChat.tsx:184 #: src/Navigation.tsx:502 #: src/view/com/auth/LoggedOut.tsx:123 #: src/view/com/modals/ListAddRemoveUsers.tsx:75 @@ -3774,8 +3920,8 @@ msgstr "맨 위로 스크롤" #: src/view/shell/desktop/LeftNav.tsx:332 #: src/view/shell/desktop/Search.tsx:194 #: src/view/shell/desktop/Search.tsx:203 -#: src/view/shell/Drawer.tsx:363 -#: src/view/shell/Drawer.tsx:364 +#: src/view/shell/Drawer.tsx:376 +#: src/view/shell/Drawer.tsx:377 msgid "Search" msgstr "검색" @@ -3795,6 +3941,10 @@ msgstr "{displayTag} 태그를 사용한 @{authorHandle} 님의 모든 게시물 msgid "Search for all posts with tag {displayTag}" msgstr "{displayTag} 태그를 사용한 모든 게시물 검색" +#: src/components/dms/NewChat.tsx:226 +msgid "Search for someone to start a conversation with." +msgstr "대화를 시작할 사람을 검색하세요." + #: src/view/com/auth/LoggedOut.tsx:105 #: src/view/com/auth/LoggedOut.tsx:106 #: src/view/com/modals/ListAddRemoveUsers.tsx:70 @@ -3805,6 +3955,10 @@ msgstr "사용자 검색하기" msgid "Search GIFs" msgstr "GIF 검색하기" +#: src/components/dms/NewChat.tsx:183 +msgid "Search profiles" +msgstr "프로필 검색" + #: src/components/dialogs/GifSelect.tsx:159 msgid "Search Tenor" msgstr "Tenor 검색" @@ -3842,7 +3996,7 @@ msgstr "이 가이드" msgid "Select {item}" msgstr "{item} 선택" -#: src/screens/Login/ChooseAccountForm.tsx:61 +#: src/screens/Login/ChooseAccountForm.tsx:85 msgid "Select account" msgstr "계정 선택" @@ -3932,11 +4086,16 @@ msgctxt "action" msgid "Send Email" msgstr "이메일 보내기" -#: src/view/shell/Drawer.tsx:296 -#: src/view/shell/Drawer.tsx:317 +#: src/view/shell/Drawer.tsx:309 +#: src/view/shell/Drawer.tsx:330 msgid "Send feedback" msgstr "피드백 보내기" +#: src/screens/Messages/Conversation/MessageInput.tsx:96 +#: src/screens/Messages/Conversation/MessageInput.web.tsx:80 +msgid "Send message" +msgstr "메시지 보내기" + #: src/components/ReportDialog/SubmitView.tsx:215 #: src/components/ReportDialog/SubmitView.tsx:219 msgid "Send report" @@ -3995,23 +4154,23 @@ msgstr "계정 설정하기" msgid "Sets Bluesky username" msgstr "Bluesky 사용자 이름을 설정합니다" -#: src/view/screens/Settings/index.tsx:436 +#: src/view/screens/Settings/index.tsx:452 msgid "Sets color theme to dark" msgstr "색상 테마를 어두움으로 설정합니다" -#: src/view/screens/Settings/index.tsx:429 +#: src/view/screens/Settings/index.tsx:445 msgid "Sets color theme to light" msgstr "색상 테마를 밝음으로 설정합니다" -#: src/view/screens/Settings/index.tsx:423 +#: src/view/screens/Settings/index.tsx:439 msgid "Sets color theme to system setting" msgstr "색상 테마를 시스템 설정에 맞춥니다" -#: src/view/screens/Settings/index.tsx:462 +#: src/view/screens/Settings/index.tsx:478 msgid "Sets dark theme to the dark theme" msgstr "어두운 테마를 완전히 어둡게 설정합니다" -#: src/view/screens/Settings/index.tsx:455 +#: src/view/screens/Settings/index.tsx:471 msgid "Sets dark theme to the dim theme" msgstr "어두운 테마를 살짝 밝게 설정합니다" @@ -4033,10 +4192,10 @@ msgstr "이미지 비율을 가로로 길게 설정합니다" #: src/Navigation.tsx:146 #: src/screens/Messages/Settings/index.tsx:21 -#: src/view/screens/Settings/index.tsx:309 +#: src/view/screens/Settings/index.tsx:322 #: src/view/shell/desktop/LeftNav.tsx:433 -#: src/view/shell/Drawer.tsx:549 -#: src/view/shell/Drawer.tsx:550 +#: src/view/shell/Drawer.tsx:562 +#: src/view/shell/Drawer.tsx:563 msgid "Settings" msgstr "설정" @@ -4086,7 +4245,7 @@ msgstr "연결된 웹사이트를 공유합니다" #: src/components/moderation/LabelPreference.tsx:136 #: src/components/moderation/PostHider.tsx:116 #: src/screens/Onboarding/StepModeration/ModerationOption.tsx:54 -#: src/view/screens/Settings/index.tsx:359 +#: src/view/screens/Settings/index.tsx:372 msgid "Show" msgstr "표시" @@ -4194,8 +4353,8 @@ msgstr "피드에 {0} 님의 게시물을 표시합니다" #: src/screens/Login/LoginForm.tsx:148 #: src/view/com/auth/SplashScreen.tsx:63 #: src/view/com/auth/SplashScreen.tsx:72 -#: src/view/com/auth/SplashScreen.web.tsx:107 -#: src/view/com/auth/SplashScreen.web.tsx:116 +#: src/view/com/auth/SplashScreen.web.tsx:112 +#: src/view/com/auth/SplashScreen.web.tsx:121 #: src/view/shell/bottom-bar/BottomBar.tsx:343 #: src/view/shell/bottom-bar/BottomBar.tsx:344 #: src/view/shell/bottom-bar/BottomBar.tsx:346 @@ -4208,11 +4367,11 @@ msgstr "피드에 {0} 님의 게시물을 표시합니다" msgid "Sign in" msgstr "로그인" -#: src/components/AccountList.tsx:109 +#: src/components/AccountList.tsx:114 msgid "Sign in as {0}" msgstr "{0}(으)로 로그인" -#: src/screens/Login/ChooseAccountForm.tsx:64 +#: src/screens/Login/ChooseAccountForm.tsx:88 msgid "Sign in as..." msgstr "로그인" @@ -4224,8 +4383,8 @@ msgstr "대화에 참여하려면 로그인하거나 계정을 만드세요!" msgid "Sign into Bluesky or create a new account" msgstr "Bluesky에 로그인하거나 새 계정 만들기" -#: src/view/screens/Settings/index.tsx:111 -#: src/view/screens/Settings/index.tsx:114 +#: src/view/screens/Settings/index.tsx:126 +#: src/view/screens/Settings/index.tsx:130 msgid "Sign out" msgstr "로그아웃" @@ -4250,11 +4409,12 @@ msgstr "가입 또는 로그인하여 대화에 참여하세요" msgid "Sign-in Required" msgstr "로그인 필요" -#: src/view/screens/Settings/index.tsx:370 +#: src/view/screens/Settings/index.tsx:382 msgid "Signed in as" msgstr "로그인한 계정" -#: src/screens/Login/ChooseAccountForm.tsx:48 +#: src/lib/hooks/useAccountSwitcher.ts:44 +#: src/screens/Login/ChooseAccountForm.tsx:60 msgid "Signed in as @{0}" msgstr "@{0}(으)로 로그인했습니다" @@ -4277,7 +4437,12 @@ msgstr "소프트웨어 개발" msgid "Something went wrong, please try again." msgstr "뭔가 잘못되었습니다. 다시 시도해 주세요." -#: src/App.native.tsx:64 +#: src/lib/hooks/useAccountSwitcher.ts:60 +#~ msgid "Sorry! We need you to enter your password." +#~ msgstr "죄송합니다. 비밀번호를 입력해 주세요." + +#: src/App.native.tsx:84 +#: src/App.web.tsx:71 msgid "Sorry! Your session expired. Please log in again." msgstr "죄송합니다. 세션이 만료되었습니다. 다시 로그인해 주세요." @@ -4309,20 +4474,24 @@ msgstr "스포츠" msgid "Square" msgstr "정사각형" -#: src/view/screens/Settings/index.tsx:862 -msgid "Status page" +#: src/components/dms/NewChat.tsx:178 +msgid "Start a new chat" +msgstr "새 대화 시작하기" + +#: src/view/screens/Settings/index.tsx:896 +msgid "Status Page" msgstr "상태 페이지" -#: src/screens/Signup/index.tsx:145 +#: src/screens/Signup/index.tsx:154 msgid "Step" msgstr "" -#: src/view/screens/Settings/index.tsx:288 +#: src/view/screens/Settings/index.tsx:301 msgid "Storage cleared, you need to restart the app now." msgstr "스토리지가 지워졌으며 지금 앱을 다시 시작해야 합니다." #: src/Navigation.tsx:218 -#: src/view/screens/Settings/index.tsx:790 +#: src/view/screens/Settings/index.tsx:806 msgid "Storybook" msgstr "스토리북" @@ -4331,7 +4500,7 @@ msgstr "스토리북" msgid "Submit" msgstr "확인" -#: src/view/screens/ProfileList.tsx:592 +#: src/view/screens/ProfileList.tsx:586 msgid "Subscribe" msgstr "구독" @@ -4352,7 +4521,7 @@ msgstr "{0} 피드 구독하기" msgid "Subscribe to this labeler" msgstr "이 라벨러 구독하기" -#: src/view/screens/ProfileList.tsx:588 +#: src/view/screens/ProfileList.tsx:582 msgid "Subscribe to this list" msgstr "이 리스트 구독하기" @@ -4379,19 +4548,19 @@ msgstr "지원" msgid "Switch Account" msgstr "계정 전환" -#: src/view/screens/Settings/index.tsx:143 +#: src/view/screens/Settings/index.tsx:157 msgid "Switch to {0}" msgstr "{0}(으)로 전환" -#: src/view/screens/Settings/index.tsx:144 +#: src/view/screens/Settings/index.tsx:158 msgid "Switches the account you are logged in to" msgstr "로그인한 계정을 전환합니다" -#: src/view/screens/Settings/index.tsx:420 +#: src/view/screens/Settings/index.tsx:436 msgid "System" msgstr "시스템" -#: src/view/screens/Settings/index.tsx:778 +#: src/view/screens/Settings/index.tsx:794 msgid "System log" msgstr "시스템 로그" @@ -4415,15 +4584,15 @@ msgstr "탭하여 전체 크기로 봅니다" msgid "Tech" msgstr "기술" -#: src/view/shell/desktop/RightNav.tsx:81 +#: src/view/shell/desktop/RightNav.tsx:85 msgid "Terms" msgstr "이용약관" #: src/Navigation.tsx:243 #: src/screens/Signup/StepInfo/Policies.tsx:49 -#: src/view/screens/Settings/index.tsx:876 +#: src/view/screens/Settings/index.tsx:884 #: src/view/screens/TermsOfService.tsx:29 -#: src/view/shell/Drawer.tsx:257 +#: src/view/shell/Drawer.tsx:259 msgid "Terms of Service" msgstr "서비스 이용약관" @@ -4449,7 +4618,7 @@ msgstr "감사합니다. 신고를 전송했습니다." msgid "That contains the following:" msgstr "텍스트 파일 내용:" -#: src/screens/Signup/index.tsx:86 +#: src/screens/Signup/index.tsx:87 msgid "That handle is already taken." msgstr "이 핸들은 이미 사용 중입니다." @@ -4533,7 +4702,7 @@ msgstr "서버에 연결하는 동안 문제가 발생했습니다" msgid "There was an issue contacting your server" msgstr "서버에 연결하는 동안 문제가 발생했습니다" -#: src/view/com/notifications/Feed.tsx:117 +#: src/view/com/notifications/Feed.tsx:118 msgid "There was an issue fetching notifications. Tap here to try again." msgstr "알림을 가져오는 동안 문제가 발생했습니다. 이곳을 탭하여 다시 시도하세요." @@ -4639,7 +4808,7 @@ msgstr "이 피드는 현재 트래픽이 많아 일시적으로 사용할 수 #: src/screens/Profile/Sections/Feed.tsx:59 #: src/view/screens/ProfileFeed.tsx:488 -#: src/view/screens/ProfileList.tsx:677 +#: src/view/screens/ProfileList.tsx:671 msgid "This feed is empty!" msgstr "이 피드는 비어 있습니다." @@ -4667,7 +4836,7 @@ msgstr "이 라벨러는 라벨을 게시하지 않았으며 활성화되어 있 msgid "This link is taking you to the following website:" msgstr "이 링크를 클릭하면 다음 웹사이트로 이동합니다:" -#: src/view/screens/ProfileList.tsx:855 +#: src/view/screens/ProfileList.tsx:849 msgid "This list is empty!" msgstr "이 리스트는 비어 있습니다." @@ -4737,12 +4906,12 @@ msgstr "이 경고는 미디어가 첨부된 게시물에만 사용할 수 있 msgid "This will delete {0} from your muted words. You can always add it back later." msgstr "뮤트한 단어에서 {0}이(가) 삭제됩니다. 나중에 언제든지 다시 추가할 수 있습니다." -#: src/view/screens/Settings/index.tsx:569 +#: src/view/screens/Settings/index.tsx:585 msgid "Thread preferences" msgstr "스레드 설정" #: src/view/screens/PreferencesThreads.tsx:53 -#: src/view/screens/Settings/index.tsx:579 +#: src/view/screens/Settings/index.tsx:595 msgid "Thread Preferences" msgstr "스레드 설정" @@ -4795,15 +4964,19 @@ msgctxt "action" msgid "Try again" msgstr "다시 시도" -#: src/view/screens/Settings/index.tsx:695 +#: src/view/screens/Settings/index.tsx:711 msgid "Two-factor authentication" msgstr "2단계 인증" +#: src/screens/Messages/Conversation/MessageInput.tsx:80 +msgid "Type your message here" +msgstr "메시지를 입력하세요" + #: src/view/com/modals/ChangeHandle.tsx:429 msgid "Type:" msgstr "유형:" -#: src/view/screens/ProfileList.tsx:480 +#: src/view/screens/ProfileList.tsx:478 msgid "Un-block list" msgstr "리스트 차단 해제" @@ -4815,7 +4988,7 @@ msgstr "리스트 언뮤트" #: src/screens/Login/index.tsx:78 #: src/screens/Login/LoginForm.tsx:136 #: src/screens/Login/SetNewPasswordForm.tsx:77 -#: src/screens/Signup/index.tsx:65 +#: src/screens/Signup/index.tsx:66 #: src/view/com/modals/ChangePassword.tsx:72 msgid "Unable to contact your service. Please check your Internet connection." msgstr "서비스에 연결할 수 없습니다. 인터넷 연결을 확인하세요." @@ -4823,7 +4996,7 @@ msgstr "서비스에 연결할 수 없습니다. 인터넷 연결을 확인하 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:181 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:286 #: src/view/com/profile/ProfileMenu.tsx:361 -#: src/view/screens/ProfileList.tsx:574 +#: src/view/screens/ProfileList.tsx:568 msgid "Unblock" msgstr "차단 해제" @@ -4876,7 +5049,7 @@ msgid "Unlike this feed" msgstr "이 피드 좋아요 취소" #: src/components/TagMenu/index.tsx:249 -#: src/view/screens/ProfileList.tsx:581 +#: src/view/screens/ProfileList.tsx:575 msgid "Unmute" msgstr "언뮤트" @@ -4893,13 +5066,17 @@ msgstr "계정 언뮤트" msgid "Unmute all {displayTag} posts" msgstr "모든 {tag} 게시물 언뮤트" +#: src/components/dms/ConvoMenu.tsx:123 +msgid "Unmute notifications" +msgstr "알림 언뮤트" + #: src/view/com/util/forms/PostDropdownBtn.tsx:273 #: src/view/com/util/forms/PostDropdownBtn.tsx:278 msgid "Unmute thread" msgstr "스레드 언뮤트" #: src/view/screens/ProfileFeed.tsx:306 -#: src/view/screens/ProfileList.tsx:565 +#: src/view/screens/ProfileList.tsx:559 msgid "Unpin" msgstr "고정 해제" @@ -5022,13 +5199,13 @@ msgstr "나를 차단한 사용자" msgid "User list by {0}" msgstr "{0} 님의 사용자 리스트" -#: src/view/screens/ProfileList.tsx:779 +#: src/view/screens/ProfileList.tsx:773 msgid "User list by <0/>" msgstr "<0/> 님의 사용자 리스트" #: src/view/com/lists/ListCard.tsx:83 #: src/view/com/modals/UserAddRemoveLists.tsx:196 -#: src/view/screens/ProfileList.tsx:777 +#: src/view/screens/ProfileList.tsx:771 msgid "User list by you" msgstr "내 사용자 리스트" @@ -5048,7 +5225,7 @@ msgstr "사용자 리스트" msgid "Username or email address" msgstr "사용자 이름 또는 이메일 주소" -#: src/view/screens/ProfileList.tsx:813 +#: src/view/screens/ProfileList.tsx:807 msgid "Users" msgstr "사용자" @@ -5072,15 +5249,15 @@ msgstr "값:" msgid "Verify {0}" msgstr "{0} 확인" -#: src/view/screens/Settings/index.tsx:901 +#: src/view/screens/Settings/index.tsx:915 msgid "Verify email" msgstr "이메일 인증" -#: src/view/screens/Settings/index.tsx:926 +#: src/view/screens/Settings/index.tsx:940 msgid "Verify my email" msgstr "내 이메일 인증하기" -#: src/view/screens/Settings/index.tsx:935 +#: src/view/screens/Settings/index.tsx:949 msgid "Verify My Email" msgstr "내 이메일 인증하기" @@ -5093,9 +5270,9 @@ msgstr "새 이메일 인증" msgid "Verify Your Email" msgstr "이메일 인증하기" -#: src/view/screens/Settings/index.tsx:852 -msgid "Version {0}" -msgstr "버전 {0}" +#: src/view/screens/Settings/index.tsx:868 +msgid "Version {appVersion} {bundleInfo}" +msgstr "버전 {appVersion} {bundleInfo}" #: src/screens/Onboarding/index.tsx:42 msgid "Video Games" @@ -5207,7 +5384,7 @@ msgstr "계정이 준비되면 알려드리겠습니다." msgid "We'll use this to help customize your experience." msgstr "이를 통해 사용자 환경을 맞춤 설정할 수 있습니다." -#: src/screens/Signup/index.tsx:133 +#: src/screens/Signup/index.tsx:142 msgid "We're so excited to have you join us!" msgstr "함께하게 되어 정말 기뻐요!" @@ -5223,7 +5400,7 @@ msgstr "죄송하지만 현재 뮤트한 단어를 불러올 수 없습니다. msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "죄송하지만 검색을 완료할 수 없습니다. 몇 분 후에 다시 시도해 주세요." -#: src/components/Lists.tsx:197 +#: src/components/Lists.tsx:200 #: src/view/screens/NotFound.tsx:48 msgid "We're sorry! We can't find the page you were looking for." msgstr "죄송합니다. 페이지를 찾을 수 없습니다." @@ -5237,7 +5414,7 @@ msgid "What are your interests?" msgstr "어떤 관심사가 있으신가요?" #: src/view/com/auth/SplashScreen.tsx:40 -#: src/view/com/auth/SplashScreen.web.tsx:81 +#: src/view/com/auth/SplashScreen.web.tsx:86 #: src/view/com/composer/Composer.tsx:307 msgid "What's up?" msgstr "무슨 일이 일어나고 있나요?" @@ -5279,6 +5456,11 @@ msgstr "이 사용자를 검토해야 하는 이유는 무엇인가요?" msgid "Wide" msgstr "가로" +#: src/screens/Messages/Conversation/MessageInput.tsx:81 +#: src/screens/Messages/Conversation/MessageInput.web.tsx:70 +msgid "Write a message" +msgstr "메시지를 입력하세요" + #: src/view/com/composer/Composer.tsx:468 msgid "Write post" msgstr "게시물 작성" @@ -5302,6 +5484,10 @@ msgstr "작가" msgid "Yes" msgstr "예" +#: src/components/dms/MessageItem.tsx:152 +msgid "Yesterday, {time}" +msgstr "어제 {time}" + #: src/screens/Deactivated.tsx:137 msgid "You are in line." msgstr "대기 중입니다." @@ -5387,7 +5573,7 @@ msgstr "피드가 없습니다." msgid "You have no lists." msgstr "리스트가 없습니다." -#: src/screens/Messages/List/index.tsx:92 +#: src/screens/Messages/List/index.tsx:176 msgid "You have no messages yet. Start a conversation with someone!" msgstr "아직 메시지가 없습니다. 사람들과 대화를 시작해 보세요!" @@ -5435,6 +5621,10 @@ msgstr "이제 이 스레드에 대한 알림을 받습니다" msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." msgstr "\"재설정 코드\"가 포함된 이메일을 받게 되면 여기에 해당 코드를 입력한 다음 새 비밀번호를 입력합니다." +#: src/screens/Messages/List/index.tsx:238 +msgid "You: {0}" +msgstr "나: {0}" + #: src/screens/Onboarding/StepModeration/index.tsx:60 msgid "You're in control" msgstr "직접 제어하세요" @@ -5458,7 +5648,7 @@ msgstr "이 글에서 단어 또는 태그를 숨기도록 설정했습니다." msgid "You've reached the end of your feed! Find some more accounts to follow." msgstr "피드 끝에 도달했습니다! 팔로우할 계정을 더 찾아보세요." -#: src/screens/Signup/index.tsx:153 +#: src/screens/Signup/index.tsx:162 msgid "Your account" msgstr "내 계정" @@ -5524,7 +5714,7 @@ msgstr "게시물을 게시했습니다" msgid "Your posts, likes, and blocks are public. Mutes are private." msgstr "게시물, 좋아요, 차단 목록은 공개됩니다. 뮤트 목록은 공개되지 않습니다." -#: src/view/screens/Settings/index.tsx:129 +#: src/view/screens/Settings/index.tsx:145 msgid "Your profile" msgstr "내 프로필" @@ -5532,6 +5722,6 @@ msgstr "내 프로필" msgid "Your reply has been published" msgstr "내 답글을 게시했습니다" -#: src/screens/Signup/index.tsx:155 +#: src/screens/Signup/index.tsx:164 msgid "Your user handle" msgstr "내 사용자 핸들" diff --git a/src/locale/locales/zh-TW/messages.po b/src/locale/locales/zh-TW/messages.po index 7858bf31ff..09d464253d 100644 --- a/src/locale/locales/zh-TW/messages.po +++ b/src/locale/locales/zh-TW/messages.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"POT-Creation-Date: 2024-04-24 09:30+0800\n" +"POT-Creation-Date: 2024-05-06 10:30+0800\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -9,13 +9,13 @@ msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "PO-Revision-Date: \n" -"Last-Translator: Frudrax Cheng \n" +"Last-Translator: Kuwa Lee \n" "Language-Team: Frudrax Cheng, Kuwa Lee, noeFly, snowleo208, Kisaragi Hiu, Yi-Jyun Pan, toto6038, cirx1e\n" "Plural-Forms: \n" #: src/view/com/modals/VerifyEmail.tsx:151 msgid "(no email)" -msgstr "(沒有郵件)" +msgstr "(沒有電子郵件)" #: src/components/ProfileHoverCard/index.web.tsx:446 #: src/screens/Profile/Header/Metrics.tsx:44 @@ -24,7 +24,7 @@ msgstr "{following} 個跟隨中" #: src/view/shell/Drawer.tsx:441 msgid "{numUnreadNotifications} unread" -msgstr "{numUnreadNotifications} 個未讀" +msgstr "{numUnreadNotifications} 個未讀通知" #: src/view/com/threadgate/WhoCanReply.tsx:158 msgid "<0/> members" @@ -43,18 +43,6 @@ msgstr "<0>{followers} <1>{pluralizedFollowers}" msgid "<0>{following} <1>following" msgstr "<0>{following} <1>個跟隨中" -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:31 -#~ msgid "<0>Choose your<1>Recommended<2>Feeds" -#~ msgstr "<0>選擇你的<1>推薦<2>訊息流" - -#: src/view/com/auth/onboarding/RecommendedFollows.tsx:38 -#~ msgid "<0>Follow some<1>Recommended<2>Users" -#~ msgstr "<0>跟隨一些<1>推薦的<2>用戶" - -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:21 -#~ msgid "<0>Welcome to<1>Bluesky" -#~ msgstr "<0>歡迎來到<1>Bluesky" - #: src/screens/Profile/Header/Handle.tsx:43 msgid "⚠Invalid Handle" msgstr "⚠無效的帳號代碼" @@ -173,7 +161,7 @@ msgstr "新增應用程式專用密碼" #: src/components/dialogs/MutedWords.tsx:157 msgid "Add mute word for configured settings" -msgstr "在設定中新增靜音文字" +msgstr "在已配置的設定中新增靜音文字" #: src/components/dialogs/MutedWords.tsx:86 msgid "Add muted words and tags" @@ -181,7 +169,7 @@ msgstr "新增靜音文字及標籤" #: src/view/com/modals/ChangeHandle.tsx:417 msgid "Add the following DNS record to your domain:" -msgstr "將以下 DNS 記錄新增到你的網域:" +msgstr "將以下 DNS 記錄新增到您的網域:" #: src/view/com/profile/ProfileMenu.tsx:263 #: src/view/com/profile/ProfileMenu.tsx:266 @@ -190,11 +178,7 @@ msgstr "新增至列表" #: src/view/com/feeds/FeedSourceCard.tsx:234 msgid "Add to my feeds" -msgstr "新增至自訂訊息流" - -#: src/view/com/auth/onboarding/RecommendedFeedsItem.tsx:139 -#~ msgid "Added" -#~ msgstr "已新增" +msgstr "加入到我的動態源" #: src/view/com/modals/ListAddRemoveUsers.tsx:191 #: src/view/com/modals/UserAddRemoveLists.tsx:144 @@ -203,11 +187,11 @@ msgstr "新增至列表" #: src/view/com/feeds/FeedSourceCard.tsx:108 msgid "Added to my feeds" -msgstr "新增至自訂訊息流" +msgstr "加入到我的動態源" #: src/view/screens/PreferencesFollowingFeed.tsx:173 msgid "Adjust the number of likes a reply must have to be shown in your feed." -msgstr "調整回覆要在你的訊息流顯示所需的最低喜歡數。" +msgstr "調整在「Following」動態中顯示屬於回復貼文的最低喜歡數門檻。" #: src/lib/moderation/useGlobalLabelStrings.ts:34 #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:117 @@ -226,12 +210,12 @@ msgstr "詳細設定" #: src/view/screens/Feeds.tsx:691 msgid "All the feeds you've saved, right in one place." -msgstr "你已儲存的所有訊息流都集中在一處。" +msgstr "以下是您保存的動態源。" #: src/screens/Login/ForgotPasswordForm.tsx:178 #: src/view/com/modals/ChangePassword.tsx:172 msgid "Already have a code?" -msgstr "已經有重設碼了?" +msgstr "已經有重置碼了?" #: src/screens/Login/ChooseAccountForm.tsx:39 msgid "Already signed in as @{0}" @@ -265,7 +249,7 @@ msgstr "發生錯誤" #: src/lib/moderation/useReportOptions.ts:26 msgid "An issue not included in these options" -msgstr "這些選項中沒有包括的問題" +msgstr "問題不在上述選項" #: src/components/hooks/useFollowMethods.ts:35 #: src/components/hooks/useFollowMethods.ts:50 @@ -274,7 +258,7 @@ msgstr "這些選項中沒有包括的問題" #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:188 #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:198 msgid "An issue occurred, please try again." -msgstr "出現問題,請重試。" +msgstr "出現問題,請再試一次。" #: src/view/com/notifications/FeedItem.tsx:236 #: src/view/com/threadgate/WhoCanReply.tsx:178 @@ -326,7 +310,7 @@ msgstr "申訴" #: src/components/moderation/LabelsOnMeDialog.tsx:202 msgid "Appeal \"{0}\" label" -msgstr "申訴標籤 \"{0}\"" +msgstr "申訴「{0}」標記" #: src/components/moderation/LabelsOnMeDialog.tsx:193 msgid "Appeal submitted." @@ -338,23 +322,23 @@ msgstr "外觀" #: src/view/screens/AppPasswords.tsx:265 msgid "Are you sure you want to delete the app password \"{name}\"?" -msgstr "你確定要刪除這個應用程式專用密碼「{name}」嗎?" +msgstr "您確定要刪除這個應用程式專用密碼「{name}」嗎?" #: src/view/com/feeds/FeedSourceCard.tsx:280 msgid "Are you sure you want to remove {0} from your feeds?" -msgstr "你確定要從你的訊息流中移除 {0} 嗎?" +msgstr "您確定要從您的動態源中移除 {0} 嗎?" #: src/view/com/composer/Composer.tsx:529 msgid "Are you sure you'd like to discard this draft?" -msgstr "你確定要捨棄此草稿嗎?" +msgstr "您確定要捨棄此草稿嗎?" #: src/components/dialogs/MutedWords.tsx:281 msgid "Are you sure?" -msgstr "你確定嗎?" +msgstr "您確定嗎?" #: src/view/com/composer/select-language/SuggestedLanguage.tsx:60 msgid "Are you writing in <0>{0}?" -msgstr "你正在使用 <0>{0} 書寫嗎?" +msgstr "您正在使用 <0>{0} 書寫嗎?" #: src/screens/Onboarding/index.tsx:26 msgid "Art" @@ -386,11 +370,11 @@ msgstr "返回" #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:144 msgid "Based on your interest in {interestsText}" -msgstr "因為你對 {interestsText} 感興趣" +msgstr "因為您對 {interestsText} 感興趣" #: src/view/screens/Settings/index.tsx:471 msgid "Basics" -msgstr "基礎資訊" +msgstr "基本設定" #: src/components/dialogs/BirthDateSettings.tsx:107 msgid "Birthday" @@ -443,11 +427,11 @@ msgstr "已封鎖帳號" #: src/view/com/profile/ProfileMenu.tsx:356 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." -msgstr "被封鎖的帳號無法在你的貼文中回覆、提及你,或以其他方式與你互動。" +msgstr "被封鎖的帳號無法在您的討論串中回覆、提及您,或以其他方式與您互動。" #: src/view/screens/ModerationBlockedAccounts.tsx:120 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." -msgstr "被封鎖的帳號無法在你的貼文中回覆、提及你,或以其他方式與你互動。你將不會看到他們所發佈的內容,同樣他們也無法查看你的內容。" +msgstr "被封鎖的帳號無法在您的討論串中回覆、提及您,或以其他方式與您互動。您將看不到他們的內容,他們也會被阻止看到您的內容。" #: src/view/com/post-thread/PostThread.tsx:313 msgid "Blocked post." @@ -455,15 +439,15 @@ msgstr "已封鎖貼文。" #: src/screens/Profile/Sections/Labels.tsx:166 msgid "Blocking does not prevent this labeler from placing labels on your account." -msgstr "封鎖並不能阻止此標記者在你的帳戶上標記標籤。" +msgstr "封鎖此帳戶不會阻止被貼上標記。" #: src/view/screens/ProfileList.tsx:633 msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." -msgstr "封鎖是公開的。被封鎖的帳號無法在你的貼文中回覆、提及你,或以其他方式與你互動。" +msgstr "封鎖資訊是公開的。被封鎖的帳號無法在您的討論串中回覆、提及您,或以其他方式與您互動。" #: src/view/com/profile/ProfileMenu.tsx:353 msgid "Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you." -msgstr "封鎖不會阻止標籤套用在你的帳戶上,但它會阻止此帳戶在你的討論串中回覆或與你進行互動。" +msgstr "封鎖此帳戶不會阻止被貼上標記,但它會阻止此帳戶在您的討論串中回覆或與您進行互動。" #: src/view/com/auth/SplashScreen.web.tsx:149 msgid "Blog" @@ -476,26 +460,11 @@ msgstr "Bluesky" #: src/view/com/auth/server-input/index.tsx:154 msgid "Bluesky is an open network where you can choose your hosting provider. Custom hosting is now available in beta for developers." -msgstr "Bluesky 是一個開放的網路,你可以自行挑選託管服務供應商。現在,開發者也可以參與自訂託管服務的測試版本。" - -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:80 -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:82 -#~ msgid "Bluesky is flexible." -#~ msgstr "Bluesky 非常靈活。" - -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:69 -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:71 -#~ msgid "Bluesky is open." -#~ msgstr "Bluesky 保持開放。" - -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:56 -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:58 -#~ msgid "Bluesky is public." -#~ msgstr "Bluesky 為公眾而生。" +msgstr "Bluesky 是一個開放的網路,您可以自行挑選託管服務供應商。自定義託管服務現已為開發人員推出測試版。" #: src/screens/Moderation/index.tsx:533 msgid "Bluesky will not show your profile and posts to logged-out users. Other apps may not honor this request. This does not make your account private." -msgstr "Bluesky 不會向未登入的使用者顯示你的個人資料和貼文。但其他應用可能不會遵照此請求,這無法確保你的帳號隱私。" +msgstr "Bluesky 的官方程式將不會向未登入的使用者顯示您的個人資料和貼文。但其他應用程式可能不會遵循這個要求,這不會使您的帳號變成非公開的。" #: src/lib/moderation/useLabelBehaviorDescription.ts:53 msgid "Blur images" @@ -503,7 +472,7 @@ msgstr "模糊圖片" #: src/lib/moderation/useLabelBehaviorDescription.ts:51 msgid "Blur images and filter from feeds" -msgstr "從訊息流中模糊圖片並過濾" +msgstr "從動態中模糊圖片並過濾" #: src/screens/Onboarding/index.tsx:33 msgid "Books" @@ -515,11 +484,8 @@ msgstr "商務" #: src/view/com/profile/ProfileSubpageHeader.tsx:157 msgid "by —" -msgstr "來自 —" +msgstr "來自 ——" -#: src/view/com/auth/onboarding/RecommendedFeedsItem.tsx:100 -#~ msgid "by {0}" -#~ msgstr "來自 {0}" #: src/components/LabelingServiceCard/index.tsx:57 msgid "By {0}" @@ -531,11 +497,11 @@ msgstr "來自 <0/>" #: src/screens/Signup/StepInfo/Policies.tsx:74 msgid "By creating an account you agree to the {els}." -msgstr "建立帳戶即表示你同意 {els}。" +msgstr "建立帳戶即表示您同意 {els}。" #: src/view/com/profile/ProfileSubpageHeader.tsx:159 msgid "by you" -msgstr "來自你" +msgstr "來自您" #: src/view/com/composer/photos/OpenCameraBtn.tsx:73 msgid "Camera" @@ -590,7 +556,7 @@ msgstr "取消修改帳號代碼" #: src/view/com/modals/crop-image/CropImage.web.tsx:135 msgid "Cancel image crop" -msgstr "取消裁剪圖片" +msgstr "取消圖片裁剪" #: src/view/com/modals/EditProfile.tsx:245 msgid "Cancel profile editing" @@ -607,7 +573,7 @@ msgstr "取消搜尋" #: src/view/com/modals/LinkWarning.tsx:106 msgid "Cancels opening the linked website" -msgstr "取消開啟連結的網站" +msgstr "取消開啟網站連結" #: src/view/com/modals/VerifyEmail.tsx:161 msgid "Change" @@ -642,40 +608,32 @@ msgstr "變更密碼" #: src/view/com/composer/select-language/SuggestedLanguage.tsx:73 msgid "Change post language to {0}" -msgstr "變更貼文的發佈語言至 {0}" +msgstr "變更貼文的發佈語言為 {0}" #: src/view/com/modals/ChangeEmail.tsx:111 msgid "Change Your Email" -msgstr "變更你的電子郵件地址" +msgstr "變更您的電子郵件地址" #: src/Navigation.tsx:302 msgid "Chat" -msgstr "" +msgstr "私訊" #: src/screens/Messages/Conversation/index.tsx:26 msgid "Chat with {chatId}" -msgstr "" +msgstr "與 {chatId} 對話" #: src/screens/Deactivated.tsx:79 #: src/screens/Deactivated.tsx:83 msgid "Check my status" msgstr "檢查我的狀態" -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:122 -#~ msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." -#~ msgstr "來看看一些推薦的訊息流吧。點擊 + 將它們新增到你的釘選訊息流清單中。" - -#: src/view/com/auth/onboarding/RecommendedFollows.tsx:186 -#~ msgid "Check out some recommended users. Follow them to see similar users." -#~ msgstr "來看看一些推薦用戶吧。跟隨他們來查看類似的用戶。" - #: src/screens/Login/LoginForm.tsx:262 msgid "Check your email for a login code and enter it here." -msgstr "在此輸入寄送至你電子郵件地址的驗證碼。" +msgstr "在此輸入寄送至您電子郵件地址的驗證碼。" #: src/view/com/modals/DeleteAccount.tsx:169 msgid "Check your inbox for an email with the confirmation code to enter below:" -msgstr "在下方輸入寄送至你電子郵件地址的驗證碼:" +msgstr "在下方輸入寄送至您電子郵件地址的驗證碼:" #: src/view/com/modals/Threadgate.tsx:72 msgid "Choose \"Everybody\" or \"Nobody\"" @@ -687,28 +645,23 @@ msgstr "選擇服務" #: src/screens/Onboarding/StepFinished.tsx:141 msgid "Choose the algorithms that power your custom feeds." -msgstr "選擇你的自訂訊息流所使用的演算法。" - -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:83 -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:85 -#~ msgid "Choose the algorithms that power your experience with custom feeds." -#~ msgstr "選擇你的自訂訊息流體驗所使用的演算法。" +msgstr "選擇提供您自定義動態的演算法。" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:104 msgid "Choose your main feeds" -msgstr "選擇你的主要訊息流" +msgstr "選擇您的主要動態源" #: src/screens/Signup/StepInfo/index.tsx:114 msgid "Choose your password" -msgstr "選擇你的密碼" +msgstr "選擇您的密碼" #: src/view/screens/Settings/index.tsx:827 msgid "Clear all legacy storage data" -msgstr "清除所有舊儲存資料" +msgstr "清除所有殘存資料" #: src/view/screens/Settings/index.tsx:830 msgid "Clear all legacy storage data (restart after this)" -msgstr "清除所有舊儲存資料(並重啟)" +msgstr "清除所有殘存資料(並重啟)" #: src/view/screens/Settings/index.tsx:839 msgid "Clear all storage data" @@ -725,7 +678,7 @@ msgstr "清除搜尋記錄" #: src/view/screens/Settings/index.tsx:828 msgid "Clears all legacy storage data" -msgstr "清除所有舊儲存資料" +msgstr "清除所有遺留資料" #: src/view/screens/Settings/index.tsx:840 msgid "Clears all storage data" @@ -737,7 +690,7 @@ msgstr "點擊這裡" #: src/components/TagMenu/index.web.tsx:138 msgid "Click here to open tag menu for {tag}" -msgstr "點擊這裡開啟 {tag} 的標籤選單" +msgstr "點擊這裡以開啟 {tag} 的標籤選單" #: src/screens/Onboarding/index.tsx:35 msgid "Climate" @@ -760,7 +713,7 @@ msgstr "關閉警告" #: src/view/com/util/BottomSheetCustomBackdrop.tsx:36 msgid "Close bottom drawer" -msgstr "關閉底部抽屜" +msgstr "關閉底欄" #: src/components/dialogs/GifSelect.tsx:294 msgid "Close dialog" @@ -818,11 +771,11 @@ msgstr "漫畫" #: src/Navigation.tsx:248 #: src/view/screens/CommunityGuidelines.tsx:32 msgid "Community Guidelines" -msgstr "社群準則" +msgstr "社群守則" #: src/screens/Onboarding/StepFinished.tsx:154 msgid "Complete onboarding and start using your account" -msgstr "完成初始設定並開始使用你的帳號" +msgstr "完成初始設定並開始使用您的帳號" #: src/screens/Signup/index.tsx:157 msgid "Complete the challenge" @@ -838,15 +791,15 @@ msgstr "撰寫回覆" #: src/screens/Onboarding/StepModeration/ModerationOption.tsx:81 msgid "Configure content filtering setting for category: {0}" -msgstr "調整類別的內容過濾設定:{0}" +msgstr "為以下類別配置內容過濾設定:{0}" #: src/components/moderation/LabelPreference.tsx:81 msgid "Configure content filtering setting for category: {name}" -msgstr "為 {name} 類別設定內容過濾" +msgstr "為 {name} 配置內容過濾設定" #: src/components/moderation/LabelPreference.tsx:244 msgid "Configured in <0>moderation settings." -msgstr "已在<0>限制設定中設定。" +msgstr "已在<0>限制設定中配置。" #: src/components/Prompt.tsx:153 #: src/components/Prompt.tsx:156 @@ -875,11 +828,11 @@ msgstr "確認刪除帳號" #: src/screens/Moderation/index.tsx:301 msgid "Confirm your age:" -msgstr "確認你的年齡:" +msgstr "確認您的年齡:" #: src/screens/Moderation/index.tsx:292 msgid "Confirm your birthdate" -msgstr "確認你的出生日期" +msgstr "確認您的出生日期" #: src/screens/Login/LoginForm.tsx:244 #: src/view/com/modals/ChangeEmail.tsx:159 @@ -897,7 +850,7 @@ msgstr "連線中…" #: src/screens/Signup/index.tsx:227 msgid "Contact support" -msgstr "聯絡支援" +msgstr "聯繫支援" #: src/components/moderation/LabelsOnMe.tsx:42 msgid "content" @@ -1026,7 +979,7 @@ msgstr "著作權政策" #: src/view/screens/ProfileFeed.tsx:103 msgid "Could not load feed" -msgstr "無法載入訊息流" +msgstr "無法加載動態" #: src/view/screens/ProfileList.tsx:909 msgid "Could not load list" @@ -1083,7 +1036,7 @@ msgstr "自訂網域" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:107 #: src/view/screens/Feeds.tsx:717 msgid "Custom feeds built by the community bring you new experiences and help you find the content you love." -msgstr "由社群打造的自訂訊息流帶來新鮮體驗,協助你找到所愛內容。" +msgstr "由社群打造的自訂動態帶來全新體驗,協助您找到所愛的內容。" #: src/view/screens/PreferencesExternalEmbeds.tsx:56 msgid "Customize media from external sites." @@ -1108,11 +1061,11 @@ msgstr "出生日期" #: src/view/screens/Settings/index.tsx:800 msgid "Debug Moderation" -msgstr "限制除錯" +msgstr "限制偵錯" #: src/view/screens/Debug.tsx:83 msgid "Debug panel" -msgstr "除錯面板" +msgstr "偵錯面板" #: src/view/com/util/forms/PostDropdownBtn.tsx:345 #: src/view/screens/AppPasswords.tsx:268 @@ -1182,7 +1135,7 @@ msgstr "有什麼想說的嗎?" #: src/view/screens/Settings/index.tsx:452 msgid "Dim" -msgstr "暗淡" +msgstr "昏暗" #: src/view/screens/AccessibilitySettings.tsx:94 msgid "Disable autoplay for GIFs" @@ -1214,16 +1167,16 @@ msgstr "捨棄草稿?" #: src/screens/Moderation/index.tsx:518 #: src/screens/Moderation/index.tsx:522 msgid "Discourage apps from showing my account to logged-out users" -msgstr "鼓勵應用程式不要向未登入用戶顯示我的帳號" +msgstr "阻撓應用程式向未登入用戶顯示我的帳號" #: src/view/com/posts/FollowingEmptyState.tsx:74 #: src/view/com/posts/FollowingEndOfFeed.tsx:75 msgid "Discover new custom feeds" -msgstr "探索新的自訂訊息流" +msgstr "探索新的自訂動態源" #: src/view/screens/Feeds.tsx:714 msgid "Discover New Feeds" -msgstr "探索新的訊息流" +msgstr "探索新的動態源" #: src/view/com/modals/EditProfile.tsx:193 msgid "Display name" @@ -1298,7 +1251,7 @@ msgstr "拖放即可新增圖片" #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:120 msgid "Due to Apple policies, adult content can only be enabled on the web after completing sign up." -msgstr "受 Apple 政策限制,成人內容只能在完成註冊後在網頁端啟用顯示。" +msgstr "受 Apple 政策限制,成人內容只能在完成註冊後在網頁端啟用。" #: src/view/com/modals/ChangeHandle.tsx:259 msgid "e.g. alice" @@ -1338,7 +1291,7 @@ msgstr "例如:多次張貼廣告的用戶。" #: src/view/com/modals/InviteCodes.tsx:97 msgid "Each code works once. You'll receive more invite codes periodically." -msgstr "每個邀請碼僅能使用一次。你將定期收到更多的邀請碼。" +msgstr "每個邀請碼僅能使用一次。您將定期收到更多的邀請碼。" #: src/view/com/lists/ListMembers.tsx:149 msgctxt "action" @@ -1361,13 +1314,13 @@ msgstr "編輯列表詳情" #: src/view/com/modals/CreateOrEditList.tsx:253 msgid "Edit Moderation List" -msgstr "編輯管理列表" +msgstr "編輯限制列表" #: src/Navigation.tsx:263 #: src/view/screens/Feeds.tsx:459 #: src/view/screens/SavedFeeds.tsx:85 msgid "Edit My Feeds" -msgstr "編輯自訂訊息流" +msgstr "編輯我的動態源" #: src/view/com/modals/EditProfile.tsx:153 msgid "Edit my profile" @@ -1386,7 +1339,7 @@ msgstr "編輯個人資料" #: src/view/com/home/HomeHeaderLayout.web.tsx:66 #: src/view/screens/Feeds.tsx:380 msgid "Edit Saved Feeds" -msgstr "編輯已儲存的訊息流" +msgstr "編輯已儲存的動態源" #: src/view/com/modals/CreateOrEditList.tsx:248 msgid "Edit User List" @@ -1394,11 +1347,11 @@ msgstr "編輯用戶列表" #: src/view/com/modals/EditProfile.tsx:194 msgid "Edit your display name" -msgstr "編輯你的顯示名稱" +msgstr "編輯您的顯示名稱" #: src/view/com/modals/EditProfile.tsx:212 msgid "Edit your profile description" -msgstr "編輯你的帳號描述" +msgstr "編輯您的帳號描述" #: src/screens/Onboarding/index.tsx:34 msgid "Education" @@ -1446,7 +1399,7 @@ msgstr "嵌入貼文" #: src/components/dialogs/Embed.tsx:101 msgid "Embed this post in your website. Simply copy the following snippet and paste it into the HTML code of your website." -msgstr "將這則貼文嵌入到你的網站。只需複製以下程式碼片段,並將其貼上到您網站的 HTML 程式碼中即可。" +msgstr "將這則貼文嵌入到您的網站。只需複製以下程式碼片段,並將其貼上到您網站的 HTML 程式碼中即可。" #: src/components/dialogs/EmbedConsent.tsx:101 msgid "Enable {0} only" @@ -1463,7 +1416,7 @@ msgstr "顯示成人內容" #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:78 #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:79 msgid "Enable adult content in your feeds" -msgstr "允許在你的訊息流中出現成人內容" +msgstr "允許在您的動態中出現成人內容" #: src/components/dialogs/EmbedConsent.tsx:82 #: src/components/dialogs/EmbedConsent.tsx:89 @@ -1476,7 +1429,7 @@ msgstr "啟用媒體播放器" #: src/view/screens/PreferencesFollowingFeed.tsx:147 msgid "Enable this setting to only see replies between people you follow." -msgstr "啟用此設定來只顯示你跟隨的人之間的回覆。" +msgstr "啟用此設定將只顯示您跟隨的人之間的回覆。" #: src/components/dialogs/EmbedConsent.tsx:94 msgid "Enable this source only" @@ -1488,7 +1441,7 @@ msgstr "啟用" #: src/screens/Profile/Sections/Feed.tsx:100 msgid "End of feed" -msgstr "訊息流的結尾" +msgstr "已經到底部啦!" #: src/view/com/modals/AddAppPasswords.tsx:167 msgid "Enter a name for this App Password" @@ -1509,36 +1462,36 @@ msgstr "輸入驗證碼" #: src/view/com/modals/ChangePassword.tsx:155 msgid "Enter the code you received to change your password." -msgstr "輸入你收到的驗證碼以更改密碼。" +msgstr "輸入您收到的驗證碼以更改密碼。" #: src/view/com/modals/ChangeHandle.tsx:371 msgid "Enter the domain you want to use" -msgstr "輸入你想使用的網域" +msgstr "輸入您想使用的網域" #: src/screens/Login/ForgotPasswordForm.tsx:119 msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." -msgstr "輸入你用於建立帳號的電子郵件。我們將向你發送重設碼,以便你設定新密碼。" +msgstr "輸入您用於建立帳號的電子郵件。我們將向您發送一個「重設碼」,來讓您設定新密碼。" #: src/components/dialogs/BirthDateSettings.tsx:108 msgid "Enter your birth date" -msgstr "輸入你的出生日期" +msgstr "輸入您的出生日期" #: src/screens/Login/ForgotPasswordForm.tsx:105 #: src/screens/Signup/StepInfo/index.tsx:92 msgid "Enter your email address" -msgstr "輸入你的電子郵件地址" +msgstr "輸入您的電子郵件地址" #: src/view/com/modals/ChangeEmail.tsx:43 msgid "Enter your new email above" -msgstr "請在上方輸入你的新電子郵件地址" +msgstr "請在上方輸入您的新電子郵件地址" #: src/view/com/modals/ChangeEmail.tsx:119 msgid "Enter your new email address below." -msgstr "請在下方輸入你的新電子郵件地址。" +msgstr "請在下方輸入您的新電子郵件地址。" #: src/screens/Login/index.tsx:101 msgid "Enter your username and password" -msgstr "輸入你的用戶名稱和密碼" +msgstr "輸入您的用戶名稱和密碼" #: src/screens/Signup/StepCaptcha/index.tsx:49 msgid "Error receiving captcha response." @@ -1575,7 +1528,7 @@ msgstr "離開圖片檢視器" #: src/view/com/modals/ListAddRemoveUsers.tsx:88 #: src/view/shell/desktop/Search.tsx:215 msgid "Exits inputting search query" -msgstr "離開搜尋文字輸入" +msgstr "退出輸入搜索查詢" #: src/view/com/lightbox/Lightbox.web.tsx:183 msgid "Expand alt text" @@ -1584,7 +1537,7 @@ msgstr "展開替代文字" #: src/view/com/composer/ComposerReplyTo.tsx:82 #: src/view/com/composer/ComposerReplyTo.tsx:85 msgid "Expand or collapse the full post you are replying to" -msgstr "展開或摺疊你要回覆的完整貼文" +msgstr "展開或摺疊您正在回覆的完整貼文" #: src/lib/moderation/useGlobalLabelStrings.ts:47 msgid "Explicit or potentially disturbing media." @@ -1592,7 +1545,7 @@ msgstr "露骨或可能令人不安的媒體內容。" #: src/lib/moderation/useGlobalLabelStrings.ts:35 msgid "Explicit sexual images." -msgstr "露骨的情色內容圖片。" +msgstr "露骨的情色圖片。" #: src/view/screens/Settings/index.tsx:736 msgid "Export my data" @@ -1611,13 +1564,13 @@ msgstr "外部媒體" #: src/components/dialogs/EmbedConsent.tsx:71 #: src/view/screens/PreferencesExternalEmbeds.tsx:67 msgid "External media may allow websites to collect information about you and your device. No information is sent or requested until you press the \"play\" button." -msgstr "外部媒體可能允許網站收集有關你和你裝置的信息。在你按下「播放」按鈕之前,將不會發送或請求任何外部資訊。" +msgstr "外部媒體可能允許網站收集有關您和您裝置的資料。在您按下「播放」按鈕之前,不會傳送或請求任何資料。" #: src/Navigation.tsx:282 #: src/view/screens/PreferencesExternalEmbeds.tsx:53 #: src/view/screens/Settings/index.tsx:629 msgid "External Media Preferences" -msgstr "外部媒體設定偏好" +msgstr "外部媒體偏好" #: src/view/screens/Settings/index.tsx:620 msgid "External media settings" @@ -1630,7 +1583,7 @@ msgstr "建立應用程式專用密碼失敗。" #: src/view/com/modals/CreateOrEditList.tsx:208 msgid "Failed to create the list. Check your internet connection and try again." -msgstr "無法建立列表。請檢查你的網路連線並重試。" +msgstr "無法建立列表。請檢查您的網路連線並重試。" #: src/view/com/util/forms/PostDropdownBtn.tsx:131 msgid "Failed to delete post, please try again" @@ -1640,26 +1593,21 @@ msgstr "無法刪除貼文,請重試" msgid "Failed to load GIFs" msgstr "無法載入 GIF" -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:110 -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:143 -#~ msgid "Failed to load recommended feeds" -#~ msgstr "無法載入推薦訊息流" - #: src/view/com/lightbox/Lightbox.tsx:83 msgid "Failed to save image: {0}" msgstr "無法儲存圖片:{0}" #: src/Navigation.tsx:203 msgid "Feed" -msgstr "訊息流" +msgstr "動態" #: src/view/com/feeds/FeedSourceCard.tsx:218 msgid "Feed by {0}" -msgstr "{0} 建立的訊息流" +msgstr "{0} 建立的動態源" #: src/view/screens/Feeds.tsx:630 msgid "Feed offline" -msgstr "訊息流已離線" +msgstr "動態源已離線" #: src/view/shell/desktop/RightNav.tsx:61 #: src/view/shell/Drawer.tsx:312 @@ -1675,19 +1623,15 @@ msgstr "意見回饋" #: src/view/shell/Drawer.tsx:477 #: src/view/shell/Drawer.tsx:478 msgid "Feeds" -msgstr "訊息流" - -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:58 -#~ msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." -#~ msgstr "訊息流由用戶建立並管理。選擇一些你覺得有趣的訊息流。" +msgstr "動態源" #: src/view/screens/SavedFeeds.tsx:157 msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." -msgstr "訊息流是用戶用一點程式技能建立的自訂演算法。更多資訊請見 <0/>。" +msgstr "動態源是一種自訂演算法,使用者只需掌握一點開發技巧即可輕鬆構建。更多資訊請見 <0/>。" #: src/screens/Onboarding/StepTopicalFeeds.tsx:80 msgid "Feeds can be topical as well!" -msgstr "訊息流也可以圍繞某些話題!" +msgstr "動態源也可以圍繞某些話題!" #: src/view/com/modals/ChangeHandle.tsx:482 msgid "File Contents" @@ -1695,33 +1639,29 @@ msgstr "檔案內容" #: src/lib/moderation/useLabelBehaviorDescription.ts:66 msgid "Filter from feeds" -msgstr "從訊息流中篩選" +msgstr "從動態源中篩選" #: src/screens/Onboarding/StepFinished.tsx:157 msgid "Finalizing" -msgstr "最終確定" +msgstr "正在完成" #: src/view/com/posts/CustomFeedEmptyState.tsx:47 #: src/view/com/posts/FollowingEmptyState.tsx:57 #: src/view/com/posts/FollowingEndOfFeed.tsx:58 msgid "Find accounts to follow" -msgstr "尋找一些要跟隨的帳號" +msgstr "尋找一些帳號來跟隨" #: src/view/screens/Search/Search.tsx:462 msgid "Find posts and users on Bluesky" msgstr "在 Bluesky 上尋找貼文和用戶" -#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:155 -#~ msgid "Finding similar accounts..." -#~ msgstr "正在尋找相似的帳號…" - #: src/view/screens/PreferencesFollowingFeed.tsx:111 msgid "Fine-tune the content you see on your Following feed." -msgstr "調整你在跟隨訊息流上所看到的內容。" +msgstr "對跟隨的動態源中的內容進行微調。" #: src/view/screens/PreferencesThreads.tsx:60 msgid "Fine-tune the discussion threads." -msgstr "調整討論主題。" +msgstr "微調討論串。" #: src/screens/Onboarding/index.tsx:38 msgid "Fitness" @@ -1775,10 +1715,6 @@ msgstr "回追蹤" msgid "Follow selected accounts and continue to the next step" msgstr "跟隨選擇的用戶並繼續下一步" -#: src/view/com/auth/onboarding/RecommendedFollows.tsx:65 -#~ msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." -#~ msgstr "開始跟隨一些用戶吧,我們可以根據你的興趣推薦更多相似用戶。" - #: src/view/com/profile/ProfileCard.tsx:226 msgid "Followed by {0}" msgstr "由 {0} 跟隨" @@ -1793,7 +1729,7 @@ msgstr "僅限已跟隨的用戶" #: src/view/com/notifications/FeedItem.tsx:165 msgid "followed you" -msgstr "已跟隨" +msgstr "已跟隨您" #: src/view/com/profile/ProfileFollowers.tsx:104 #: src/view/screens/ProfileFollowers.tsx:25 @@ -1813,7 +1749,7 @@ msgstr "跟隨中:{0}" #: src/view/screens/Settings/index.tsx:548 msgid "Following feed preferences" -msgstr "跟隨訊息流設定偏好" +msgstr "跟隨動態源偏好" #: src/Navigation.tsx:269 #: src/view/com/home/HomeHeaderLayout.web.tsx:54 @@ -1821,15 +1757,15 @@ msgstr "跟隨訊息流設定偏好" #: src/view/screens/PreferencesFollowingFeed.tsx:104 #: src/view/screens/Settings/index.tsx:557 msgid "Following Feed Preferences" -msgstr "跟隨訊息流設定偏好" +msgstr "跟隨動態源偏好" #: src/screens/Profile/Header/Handle.tsx:24 msgid "Follows you" -msgstr "跟隨你" +msgstr "跟隨您" #: src/view/com/profile/ProfileCard.tsx:151 msgid "Follows You" -msgstr "跟隨你" +msgstr "跟隨您" #: src/screens/Onboarding/index.tsx:43 msgid "Food" @@ -1837,11 +1773,11 @@ msgstr "食物" #: src/view/com/modals/DeleteAccount.tsx:111 msgid "For security reasons, we'll need to send a confirmation code to your email address." -msgstr "為了保護你的帳號安全,我們需要將驗證碼發送到你的電子郵件地址。" +msgstr "為了保護您的帳號安全,我們需要將驗證碼發送到您的電子郵件地址。" #: src/view/com/modals/AddAppPasswords.tsx:210 msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." -msgstr "為了保護你的帳號安全,你將無法再次查看此內容。如果你丟失了此密碼,你將需要產生一個新密碼。" +msgstr "為了保護您的帳號安全,您將無法再次查看此內容。如果您丟失了此密碼,您將需要再產生一個新的密碼。" #: src/screens/Login/index.tsx:129 #: src/screens/Login/index.tsx:144 @@ -1858,7 +1794,7 @@ msgstr "忘記?" #: src/lib/moderation/useReportOptions.ts:52 msgid "Frequently Posts Unwanted Content" -msgstr "經常發佈無關內容" +msgstr "頻繁發佈不當內容" #: src/screens/Hashtag.tsx:118 msgid "From @{sanitizedAuthor}" @@ -1918,11 +1854,6 @@ msgstr "前往首頁" msgid "Go Home" msgstr "前往首頁" -#: src/view/screens/Search/Search.tsx:827 -#: src/view/shell/desktop/Search.tsx:263 -#~ msgid "Go to @{queryMaybeHandle}" -#~ msgstr "前往 @{queryMaybeHandle}" - #: src/screens/Login/ForgotPasswordForm.tsx:172 #: src/view/com/modals/ChangePassword.tsx:169 msgid "Go to next" @@ -1930,7 +1861,7 @@ msgstr "前往下一步" #: src/lib/moderation/useGlobalLabelStrings.ts:46 msgid "Graphic Media" -msgstr "平面媒體" +msgstr "影像媒體" #: src/view/com/modals/ChangeHandle.tsx:267 msgid "Handle" @@ -1963,19 +1894,19 @@ msgstr "幫助" #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:140 msgid "Here are some accounts for you to follow" -msgstr "這裡有一些你可以跟隨的帳號" +msgstr "這裡有一些您可以跟隨的帳號" #: src/screens/Onboarding/StepTopicalFeeds.tsx:89 msgid "Here are some popular topical feeds. You can choose to follow as many as you like." -msgstr "這裡有一些熱門的話題訊息流。跟隨的訊息流數量沒有限制。" +msgstr "這裡有一些熱門的話題動態源。跟隨的動態源數量沒有限制。" #: src/screens/Onboarding/StepTopicalFeeds.tsx:84 msgid "Here are some topical feeds based on your interests: {interestsText}. You can choose to follow as many as you like." -msgstr "這裡有一些根據你的興趣({interestsText})所推薦的熱門的話題訊息流。跟隨的訊息流數量沒有限制。" +msgstr "這裡有一些根據您的興趣({interestsText})所推薦的熱門話題動態源。跟隨的動態源數量沒有限制。" #: src/view/com/modals/AddAppPasswords.tsx:154 msgid "Here is your app password." -msgstr "這是你的應用程式專用密碼。" +msgstr "這是您的應用程式專用密碼。" #: src/components/moderation/ContentHider.tsx:115 #: src/components/moderation/LabelPreference.tsx:134 @@ -2015,27 +1946,27 @@ msgstr "隱藏用戶列表" #: src/view/com/posts/FeedErrorMessage.tsx:111 msgid "Hmm, some kind of issue occurred when contacting the feed server. Please let the feed owner know about this issue." -msgstr "唔,與訊息流伺服器連線時發生了某種問題。請告訴該訊息流的擁有者這個問題。" +msgstr "唔,與動態源的伺服器連線時發生了某種問題。請告訴該動態源的擁有者這個問題。" #: src/view/com/posts/FeedErrorMessage.tsx:99 msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." -msgstr "唔,訊息流伺服器似乎設定錯誤。請告訴該訊息流的擁有者這個問題。" +msgstr "唔,動態源的伺服器似乎設定錯誤。請告訴該動態源的擁有者這個問題。" #: src/view/com/posts/FeedErrorMessage.tsx:105 msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." -msgstr "唔,訊息流伺服器似乎已離線。請告訴該訊息流的擁有者這個問題。" +msgstr "唔,動態源的伺服器似乎已離線。請告訴該動態源的擁有者這個問題。" #: src/view/com/posts/FeedErrorMessage.tsx:102 msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." -msgstr "唔,訊息流伺服器給出了錯誤的回應。請告訴該訊息流的擁有者這個問題。" +msgstr "唔,動態源的伺服器給出了錯誤的回應。請告訴該動態源的擁有者這個問題。" #: src/view/com/posts/FeedErrorMessage.tsx:96 msgid "Hmm, we're having trouble finding this feed. It may have been deleted." -msgstr "唔,我們無法找到這個訊息流,它可能已被刪除。" +msgstr "唔,我們無法找到這個動態源,它可能已被刪除。" #: src/screens/Moderation/index.tsx:59 msgid "Hmmmm, it seems we're having trouble loading this data. See below for more details. If this issue persists, please contact us." -msgstr "唔,看起來我們在載入這些資料時遇到了問題,詳情請參閱下方。如果問題持續存在,請聯絡我們。" +msgstr "唔,看起來我們在載入這些資料時遇到了問題,請參閱下方詳情。如果問題持續存在,請聯繫我們。" #: src/screens/Profile/ErrorState.tsx:31 msgid "Hmmmm, we couldn't load that moderation service." @@ -2088,19 +2019,19 @@ msgstr "若不勾選,則預設為全年齡向。" #: src/screens/Signup/StepInfo/Policies.tsx:83 msgid "If you are not yet an adult according to the laws of your country, your parent or legal guardian must read these Terms on your behalf." -msgstr "如果根據你所在國家的法律,你尚未成年,則你的父母或法定監護人必須代表你閱讀這些條款。" +msgstr "如果根據您所在國家的法律,您尚未成年,則您的父母或法定監護人必須代表您閱讀這些條款。" #: src/view/screens/ProfileList.tsx:612 msgid "If you delete this list, you won't be able to recover it." -msgstr "如果刪除這個列表,你將無法恢復它。" +msgstr "如果刪除這個列表,您將無法恢復它。" #: src/view/com/util/forms/PostDropdownBtn.tsx:342 msgid "If you remove this post, you won't be able to recover it." -msgstr "如果刪除這則貼文,你將無法恢復它。" +msgstr "如果刪除這則貼文,您將無法恢復它。" #: src/view/com/modals/ChangePassword.tsx:150 msgid "If you want to change your password, we will send you a code to verify that this is your account." -msgstr "如果你想更改密碼,我們將向你發送一個驗證碼以確認這是你的帳號。" +msgstr "如果您想更改密碼,我們將向您發送一個驗證碼以確認這是您的帳號。" #: src/lib/moderation/useReportOptions.ts:36 msgid "Illegal and Urgent" @@ -2120,7 +2051,7 @@ msgstr "冒充或虛假聲明身份或隸屬關係" #: src/screens/Login/SetNewPasswordForm.tsx:127 msgid "Input code sent to your email for password reset" -msgstr "輸入發送到你電子郵件地址的重設碼以重設密碼" +msgstr "輸入發送到您電子郵件地址的重設碼以重設密碼" #: src/view/com/modals/DeleteAccount.tsx:184 msgid "Input confirmation code for account deletion" @@ -2140,7 +2071,7 @@ msgstr "輸入密碼以刪除帳號" #: src/screens/Login/LoginForm.tsx:257 msgid "Input the code which has been emailed to you" -msgstr "輸入寄送至你電子郵件地址的驗證碼" +msgstr "輸入寄送至您電子郵件地址的驗證碼" #: src/screens/Login/LoginForm.tsx:212 msgid "Input the password tied to {identifier}" @@ -2152,15 +2083,15 @@ msgstr "輸入註冊時使用的用戶名稱或電子郵件地址" #: src/screens/Login/LoginForm.tsx:211 msgid "Input your password" -msgstr "輸入你的密碼" +msgstr "輸入您的密碼" #: src/view/com/modals/ChangeHandle.tsx:390 msgid "Input your preferred hosting provider" -msgstr "輸入你的託管服務供應商" +msgstr "輸入您的託管服務供應商" #: src/screens/Signup/StepHandle.tsx:63 msgid "Input your user handle" -msgstr "輸入你的帳號代碼" +msgstr "輸入您的帳號代碼" #: src/screens/Login/LoginForm.tsx:126 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:71 @@ -2185,7 +2116,7 @@ msgstr "邀請碼" #: src/screens/Signup/state.ts:278 msgid "Invite code not accepted. Check that you input it correctly and try again." -msgstr "邀請碼無效。請檢查你輸入的內容是否正確,然後重試。" +msgstr "邀請碼無效。請檢查您輸入的內容是否正確,然後重試。" #: src/view/com/modals/InviteCodes.tsx:171 msgid "Invite codes: {0} available" @@ -2197,7 +2128,7 @@ msgstr "邀請碼:1 個可用" #: src/screens/Onboarding/StepFollowingFeed.tsx:65 msgid "It shows posts from the people you follow as they happen." -msgstr "它會即時顯示你所跟隨的人發佈的貼文。" +msgstr "它會即時顯示您所跟隨的人發佈的貼文。" #: src/view/com/auth/SplashScreen.web.tsx:152 msgid "Jobs" @@ -2209,35 +2140,35 @@ msgstr "新聞學" #: src/components/moderation/LabelsOnMe.tsx:59 msgid "label has been placed on this {labelTarget}" -msgstr "此標籤已放置於 {labelTarget} 上" +msgstr "此標記已放置於 {labelTarget} 上" #: src/components/moderation/ContentHider.tsx:144 msgid "Labeled by {0}." -msgstr "由 {0} 標註。" +msgstr "由 {0} 標記。" #: src/components/moderation/ContentHider.tsx:142 msgid "Labeled by the author." -msgstr "由作者標註。" +msgstr "由作者標記。" #: src/view/screens/Profile.tsx:191 msgid "Labels" -msgstr "標籤" +msgstr "標記" #: src/screens/Profile/Sections/Labels.tsx:156 msgid "Labels are annotations on users and content. They can be used to hide, warn, and categorize the network." -msgstr "標籤是對用戶和內容的標註,可用於隱藏、警告和對網路進行分類。" +msgstr "標記是對用戶和內容的標註,可用於隱藏、警告和對網路進行分類。" #: src/components/moderation/LabelsOnMe.tsx:61 msgid "labels have been placed on this {labelTarget}" -msgstr "此標籤已放置於 {labelTarget} 上" +msgstr "此標記已放置於 {labelTarget} 上" #: src/components/moderation/LabelsOnMeDialog.tsx:62 msgid "Labels on your account" -msgstr "你帳戶上的標籤" +msgstr "您帳戶上的標記" #: src/components/moderation/LabelsOnMeDialog.tsx:64 msgid "Labels on your content" -msgstr "你內容上的標籤" +msgstr "您內容上的標記" #: src/view/com/composer/select-language/SelectLangBtn.tsx:104 msgid "Language selection" @@ -2297,12 +2228,12 @@ msgstr "尚未完成。" #: src/view/screens/Settings/index.tsx:292 msgid "Legacy storage cleared, you need to restart the app now." -msgstr "舊儲存資料已清除,你需要立即重新啟動應用程式。" +msgstr "遺留資料已清除,您需要立即重新啟動應用程式。" #: src/screens/Login/index.tsx:130 #: src/screens/Login/index.tsx:145 msgid "Let's get your password reset!" -msgstr "讓我們來重設你的密碼吧!" +msgstr "讓我們來重設您的密碼吧!" #: src/screens/Onboarding/StepFinished.tsx:157 msgid "Let's go!" @@ -2319,19 +2250,19 @@ msgstr "喜歡" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:267 #: src/view/screens/ProfileFeed.tsx:585 msgid "Like this feed" -msgstr "喜歡這個訊息流" +msgstr "對這個動態源按喜歡" #: src/components/LikesDialog.tsx:87 #: src/Navigation.tsx:208 #: src/Navigation.tsx:213 msgid "Liked by" -msgstr "喜歡" +msgstr "按喜歡的用戶" #: src/screens/Profile/ProfileLabelerLikedBy.tsx:29 #: src/view/screens/PostLikedBy.tsx:27 #: src/view/screens/ProfileFeedLikedBy.tsx:27 msgid "Liked By" -msgstr "喜歡" +msgstr "按喜歡的用戶" #: src/view/com/feeds/FeedSourceCard.tsx:268 msgid "Liked by {0} {1}" @@ -2349,11 +2280,11 @@ msgstr "{likeCount} 個 {0} 喜歡" #: src/view/com/notifications/FeedItem.tsx:169 msgid "liked your custom feed" -msgstr "喜歡你的自訂訊息流" +msgstr "已按喜歡您的自訂動態流" #: src/view/com/notifications/FeedItem.tsx:154 msgid "liked your post" -msgstr "喜歡你的貼文" +msgstr "已按喜歡您的貼文" #: src/view/screens/Profile.tsx:196 msgid "Likes" @@ -2393,11 +2324,11 @@ msgstr "列表名稱" #: src/view/screens/ProfileList.tsx:327 msgid "List unblocked" -msgstr "解除封鎖列表" +msgstr "已解除封鎖的列表" #: src/view/screens/ProfileList.tsx:299 msgid "List unmuted" -msgstr "解除靜音列表" +msgstr "已解除靜音的列表" #: src/Navigation.tsx:121 #: src/view/screens/Profile.tsx:192 @@ -2452,11 +2383,11 @@ msgstr "看起來像是 XXXXX-XXXXX" #: src/view/com/modals/LinkWarning.tsx:79 msgid "Make sure this is where you intend to go!" -msgstr "請確認這是你想要去的的地方!" +msgstr "請確認這是您想要去的的地方!" #: src/components/dialogs/MutedWords.tsx:82 msgid "Manage your muted words and tags" -msgstr "管理你靜音的文字和標籤" +msgstr "管理您靜音的文字和標籤" #: src/view/screens/AccessibilitySettings.tsx:89 #: src/view/screens/Profile.tsx:195 @@ -2465,11 +2396,11 @@ msgstr "媒體" #: src/view/com/threadgate/WhoCanReply.tsx:139 msgid "mentioned users" -msgstr "提及的用戶" +msgstr "被提及的用戶" #: src/view/com/modals/Threadgate.tsx:93 msgid "Mentioned users" -msgstr "提及的用戶" +msgstr "被提及的用戶" #: src/view/com/util/ViewHeader.tsx:89 #: src/view/screens/Search/Search.tsx:649 @@ -2482,18 +2413,18 @@ msgstr "來自伺服器的訊息:{0}" #: src/screens/Messages/List/index.tsx:34 msgid "Message settings" -msgstr "" +msgstr "訊息設定" #: src/Navigation.tsx:517 #: src/screens/Messages/List/index.tsx:103 #: src/view/shell/bottom-bar/BottomBar.tsx:261 #: src/view/shell/desktop/LeftNav.tsx:360 msgid "Messages" -msgstr "" +msgstr "訊息" #: src/Navigation.tsx:307 msgid "Messaging settings" -msgstr "" +msgstr "訊息設定" #: src/lib/moderation/useReportOptions.ts:45 msgid "Misleading Account" @@ -2512,17 +2443,17 @@ msgstr "限制詳情" #: src/view/com/lists/ListCard.tsx:93 #: src/view/com/modals/UserAddRemoveLists.tsx:206 msgid "Moderation list by {0}" -msgstr "{0} 建立的限制列表" +msgstr "由 {0} 建立的限制列表" #: src/view/screens/ProfileList.tsx:791 msgid "Moderation list by <0/>" -msgstr " 建立的限制列表" +msgstr "由 建立的限制列表" #: src/view/com/lists/ListCard.tsx:91 #: src/view/com/modals/UserAddRemoveLists.tsx:204 #: src/view/screens/ProfileList.tsx:789 msgid "Moderation list by you" -msgstr "你建立的限制列表" +msgstr "您建立的限制列表" #: src/view/com/modals/CreateOrEditList.tsx:199 msgid "Moderation list created" @@ -2556,7 +2487,7 @@ msgstr "限制工具" #: src/components/moderation/ModerationDetailsDialog.tsx:48 #: src/lib/moderation/useModerationCauseDescription.ts:40 msgid "Moderator has chosen to set a general warning on the content." -msgstr "限制選擇對內容設定一般警告。" +msgstr "限制者已選擇對內容設定普通警告。" #: src/view/com/post-thread/PostThreadItem.tsx:538 msgid "More" @@ -2564,7 +2495,7 @@ msgstr "更多" #: src/view/shell/desktop/Feeds.tsx:65 msgid "More feeds" -msgstr "更多訊息流" +msgstr "更多動態源" #: src/view/screens/ProfileList.tsx:601 msgid "More options" @@ -2572,7 +2503,7 @@ msgstr "更多選項" #: src/view/screens/PreferencesThreads.tsx:82 msgid "Most-liked replies first" -msgstr "最多按喜歡數優先" +msgstr "最多喜歡數優先" #: src/components/TagMenu/index.tsx:249 msgid "Mute" @@ -2645,11 +2576,11 @@ msgstr "已靜音帳號" #: src/view/screens/ModerationMutedAccounts.tsx:120 msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." -msgstr "已靜音的帳號將不會在你的通知或時間線中顯示,被靜音的帳號將不會收到通知。" +msgstr "已靜音的帳號將不會在您的通知或動態中顯示,靜音資訊是完全非公開的。" #: src/lib/moderation/useModerationCauseDescription.ts:85 msgid "Muted by \"{0}\"" -msgstr "被\"{0}\"靜音" +msgstr "被「{0}」靜音" #: src/screens/Moderation/index.tsx:231 msgid "Muted words & tags" @@ -2657,7 +2588,7 @@ msgstr "靜音文字和標籤" #: src/view/screens/ProfileList.tsx:623 msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." -msgstr "封鎖是私人的。被封鎖的帳號可以與你互動,但你將無法看到他們的貼文或收到來自他們的通知。" +msgstr "封鎖是私人的。被封鎖的帳號可以與您互動,但您將無法看到他們的貼文或收到來自他們的通知。" #: src/components/dialogs/BirthDateSettings.tsx:35 #: src/components/dialogs/BirthDateSettings.tsx:38 @@ -2666,7 +2597,7 @@ msgstr "我的生日" #: src/view/screens/Feeds.tsx:688 msgid "My Feeds" -msgstr "自定訊息流" +msgstr "我的動態源" #: src/view/shell/desktop/LeftNav.tsx:68 msgid "My Profile" @@ -2674,11 +2605,11 @@ msgstr "我的個人資料" #: src/view/screens/Settings/index.tsx:591 msgid "My saved feeds" -msgstr "我儲存的訊息流" +msgstr "我儲存的動態源" #: src/view/screens/Settings/index.tsx:597 msgid "My Saved Feeds" -msgstr "我儲存的訊息流" +msgstr "我儲存的動態源" #: src/view/com/modals/AddAppPasswords.tsx:180 #: src/view/com/modals/CreateOrEditList.tsx:293 @@ -2707,20 +2638,15 @@ msgstr "切換到下一畫面" #: src/view/shell/Drawer.tsx:70 msgid "Navigates to your profile" -msgstr "切換到你的個人檔案" +msgstr "切換到您的個人檔案" #: src/components/ReportDialog/SelectReportOptionView.tsx:123 msgid "Need to report a copyright violation?" msgstr "需要檢舉侵權嗎?" -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:72 -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:74 -#~ msgid "Never lose access to your followers and data." -#~ msgstr "永遠不會失去對你的跟隨者和資料的存取權。" - #: src/screens/Onboarding/StepFinished.tsx:125 msgid "Never lose access to your followers or data." -msgstr "永遠不會失去對你的跟隨者或資料的存取權。" +msgstr "永遠不會失去對您的跟隨者或資料的存取權。" #: src/view/com/modals/ChangeHandle.tsx:520 msgid "Nevermind, create a handle for me" @@ -2791,11 +2717,6 @@ msgstr "新聞" msgid "Next" msgstr "下一個" -#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:103 -#~ msgctxt "action" -#~ msgid "Next" -#~ msgstr "下一個" - #: src/view/com/lightbox/Lightbox.web.tsx:169 msgid "Next image" msgstr "下一張圖片" @@ -2869,7 +2790,7 @@ msgstr "沒有人" #: src/components/LikedByList.tsx:79 #: src/components/LikesDialog.tsx:99 msgid "Nobody has liked this yet. Maybe you should be the first!" -msgstr "還沒有人喜歡這個,也許你應該成為第一個!" +msgstr "還沒有人按喜歡,也許您應該成為第一個!" #: src/lib/moderation/useGlobalLabelStrings.ts:42 msgid "Non-sexual Nudity" @@ -2897,7 +2818,7 @@ msgstr "關於分享的注意事項" #: src/screens/Moderation/index.tsx:540 msgid "Note: Bluesky is an open and public network. This setting only limits the visibility of your content on the Bluesky app and website, and other apps may not respect this setting. Your content may still be shown to logged-out users by other apps and websites." -msgstr "注意:Bluesky 是一個開放且公開的網路。此設定僅限制你在 Bluesky 應用程式和網站上的內容可見性,其他應用程式可能不尊重此設定。你的內容仍可能由其他應用程式和網站顯示給未登入的使用者。" +msgstr "注意:Bluesky 是一個開放且公開的網路。此設定僅限制您在 Bluesky 應用程式和網站上的內容可見性,其他應用程式可能不尊遵循這樣的規則。您的內容仍可能由其他應用程式和網站顯示給未登入的使用者。" #: src/Navigation.tsx:512 #: src/view/screens/Notifications.tsx:124 @@ -2915,11 +2836,11 @@ msgstr "裸露" #: src/lib/moderation/useReportOptions.ts:71 msgid "Nudity or adult content not labeled as such" -msgstr "未貼上此類標籤的裸露或成人內容" +msgstr "未貼上此類標記的裸露或成人內容" #: src/screens/Signup/index.tsx:145 msgid "of" -msgstr "of" +msgstr "的" #: src/lib/moderation/useLabelBehaviorDescription.ts:11 msgid "Off" @@ -2984,7 +2905,7 @@ msgstr "開啟表情符號選擇器" #: src/view/screens/ProfileFeed.tsx:311 msgid "Open feed options menu" -msgstr "開啟訊息流選項選單" +msgstr "開啟動態選項選單" #: src/view/screens/Settings/index.tsx:686 msgid "Open links with in-app browser" @@ -3045,17 +2966,17 @@ msgstr "開啟裝置相簿" #: src/view/screens/Settings/index.tsx:621 msgid "Opens external embeds settings" -msgstr "開啟外部嵌入設定" +msgstr "開啟外部連結嵌入設定" #: src/view/com/auth/SplashScreen.tsx:50 #: src/view/com/auth/SplashScreen.web.tsx:94 msgid "Opens flow to create a new Bluesky account" -msgstr "開始流程以建立新的 Bluesky 帳戶" +msgstr "開始建立新的 Bluesky 帳戶的流程" #: src/view/com/auth/SplashScreen.tsx:65 #: src/view/com/auth/SplashScreen.web.tsx:109 msgid "Opens flow to sign into your existing Bluesky account" -msgstr "開啟流程以登入你現有的 Bluesky 帳戶" +msgstr "開始登入您現有的 Bluesky 帳戶流程" #: src/view/com/composer/photos/SelectGifBtn.tsx:37 msgid "Opens GIF select dialog" @@ -3071,19 +2992,19 @@ msgstr "開啟帳號刪除的確認彈窗。需要電子郵件驗證碼" #: src/view/screens/Settings/index.tsx:715 msgid "Opens modal for changing your Bluesky password" -msgstr "開啟用於修改你 Bluesky 密碼的彈窗" +msgstr "開啟修改 Bluesky 密碼的彈窗" #: src/view/screens/Settings/index.tsx:670 msgid "Opens modal for choosing a new Bluesky handle" -msgstr "開啟用於創建新 Bluesky 帳號代碼的彈窗" +msgstr "開啟創建新 Bluesky 帳號代碼的彈窗" #: src/view/screens/Settings/index.tsx:738 msgid "Opens modal for downloading your Bluesky account data (repository)" -msgstr "開啟用於下載 Bluesky 帳戶數據(存儲庫)的彈窗" +msgstr "開啟下載 Bluesky 帳戶數據(存儲庫)的彈窗" #: src/view/screens/Settings/index.tsx:927 msgid "Opens modal for email verification" -msgstr "開啟用於驗證電子郵件的彈窗" +msgstr "開啟驗證電子郵件的彈窗" #: src/view/com/modals/ChangeHandle.tsx:283 msgid "Opens modal for using custom domain" @@ -3100,27 +3021,27 @@ msgstr "開啟密碼重設表單" #: src/view/com/home/HomeHeaderLayout.web.tsx:67 #: src/view/screens/Feeds.tsx:381 msgid "Opens screen to edit Saved Feeds" -msgstr "開啟編輯已儲存訊息流的畫面" +msgstr "開啟編輯已儲存動態源的畫面" #: src/view/screens/Settings/index.tsx:592 msgid "Opens screen with all saved feeds" -msgstr "開啟包含所有已儲存訊息流的畫面" +msgstr "開啟包含所有已儲存動態源的畫面" #: src/view/screens/Settings/index.tsx:648 msgid "Opens the app password settings" -msgstr "開啟應用程式專用密碼設定的畫面" +msgstr "開啟應用程式專用密碼設定畫面" #: src/view/screens/Settings/index.tsx:549 msgid "Opens the Following feed preferences" -msgstr "開啟跟隨訊息流設定偏好" +msgstr "開啟跟隨動態源設定偏好" #: src/view/com/modals/LinkWarning.tsx:93 msgid "Opens the linked website" -msgstr "開啟已連結的網站" +msgstr "開啟網站連結" #: src/screens/Messages/List/index.tsx:35 msgid "Opens the message settings page" -msgstr "" +msgstr "打開私訊設定頁面" #: src/view/screens/Settings/index.tsx:788 #: src/view/screens/Settings/index.tsx:798 @@ -3133,7 +3054,7 @@ msgstr "開啟系統日誌頁面" #: src/view/screens/Settings/index.tsx:570 msgid "Opens the threads preferences" -msgstr "開啟對話串設定偏好" +msgstr "開啟對話串偏好" #: src/view/com/util/forms/DropdownButton.tsx:280 msgid "Option {0} of {numItems}" @@ -3145,7 +3066,7 @@ msgstr "在以下提供額外訊息(可選):" #: src/view/com/modals/Threadgate.tsx:89 msgid "Or combine these options:" -msgstr "或者選擇組合這些選項:" +msgstr "或者組合這些選項:" #: src/lib/moderation/useReportOptions.ts:25 msgid "Other" @@ -3205,11 +3126,11 @@ msgstr "跟隨 @{0} 的人" #: src/view/com/lightbox/Lightbox.tsx:66 msgid "Permission to access camera roll is required." -msgstr "需要相機的存取權限。" +msgstr "需要相機權限。" #: src/view/com/lightbox/Lightbox.tsx:72 msgid "Permission to access camera roll was denied. Please enable it in your system settings." -msgstr "相機的存取權限已被拒絕,請在系統設定中啟用。" +msgstr "相機權限已遭拒絕,請在系統設定中啟用。" #: src/screens/Onboarding/index.tsx:31 msgid "Pets" @@ -3222,15 +3143,15 @@ msgstr "適合成年人的圖像。" #: src/view/screens/ProfileFeed.tsx:303 #: src/view/screens/ProfileList.tsx:565 msgid "Pin to home" -msgstr "固定到首頁" +msgstr "釘選到首頁" #: src/view/screens/ProfileFeed.tsx:306 msgid "Pin to Home" -msgstr "固定到首頁" +msgstr "釘選到首頁" #: src/view/screens/SavedFeeds.tsx:89 msgid "Pinned Feeds" -msgstr "固定訊息流列表" +msgstr "釘選的動態源列表" #: src/view/com/util/post-embeds/GifEmbed.tsx:31 msgid "Play" @@ -3255,11 +3176,11 @@ msgstr "播放 GIF" #: src/screens/Signup/state.ts:241 msgid "Please choose your handle." -msgstr "請選擇你的帳號代碼。" +msgstr "請設定您的帳號代碼。" #: src/screens/Signup/state.ts:234 msgid "Please choose your password." -msgstr "請選擇你的密碼。" +msgstr "請設定您的密碼。" #: src/screens/Signup/state.ts:251 msgid "Please complete the verification captcha." @@ -3267,7 +3188,7 @@ msgstr "請完成 Captcha 驗證。" #: src/view/com/modals/ChangeEmail.tsx:69 msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." -msgstr "更改前請先確認你的電子郵件地址。這是電子郵件更新工具的臨時要求,此限制將很快被移除。" +msgstr "更改前請先確認您的電子郵件地址。這是電子郵件更新工具的臨時要求,此限制將很快被移除。" #: src/view/com/modals/AddAppPasswords.tsx:91 msgid "Please enter a name for your app password. All spaces is not allowed." @@ -3283,23 +3204,23 @@ msgstr "請輸入有效的文字或標籤進行靜音" #: src/screens/Signup/state.ts:220 msgid "Please enter your email." -msgstr "請輸入你的電子郵件。" +msgstr "請輸入您的電子郵件。" #: src/view/com/modals/DeleteAccount.tsx:191 msgid "Please enter your password as well:" -msgstr "請輸入你的密碼:" +msgstr "請輸入您的密碼:" #: src/components/moderation/LabelsOnMeDialog.tsx:222 msgid "Please explain why you think this label was incorrectly applied by {0}" -msgstr "請解釋你認為 {0} 不正確套用此標籤的原因" +msgstr "請解釋您認為 {0} 不正確套用此標記的原因" #: src/view/com/modals/VerifyEmail.tsx:110 msgid "Please Verify Your Email" -msgstr "請驗證你的電子郵件地址" +msgstr "請驗證您的電子郵件地址" #: src/view/com/composer/Composer.tsx:233 msgid "Please wait for your link card to finish loading" -msgstr "請等待你的連結卡載入完畢" +msgstr "請等待您的連結預覽載入完畢" #: src/screens/Onboarding/index.tsx:37 msgid "Politics" @@ -3341,12 +3262,12 @@ msgstr "貼文已隱藏" #: src/components/moderation/ModerationDetailsDialog.tsx:97 #: src/lib/moderation/useModerationCauseDescription.ts:99 msgid "Post Hidden by Muted Word" -msgstr "貼文因靜音文字設定而被隱藏" +msgstr "貼文因靜音文字而被隱藏" #: src/components/moderation/ModerationDetailsDialog.tsx:100 #: src/lib/moderation/useModerationCauseDescription.ts:108 msgid "Post Hidden by You" -msgstr "你靜音了這則貼文" +msgstr "被您靜音的貼文" #: src/view/com/composer/select-language/SelectLangBtn.tsx:87 msgid "Post language" @@ -3371,7 +3292,7 @@ msgstr "貼文" #: src/components/dialogs/MutedWords.tsx:89 msgid "Posts can be muted based on their text, their tags, or both." -msgstr "貼文可以根據所包含的文字和標籤來設定靜音。" +msgstr "可以靜音貼文所包含的文字和標籤。" #: src/view/com/posts/FeedErrorMessage.tsx:64 msgid "Posts hidden" @@ -3439,7 +3360,7 @@ msgstr "個人檔案已更新" #: src/view/screens/Settings/index.tsx:940 msgid "Protect your account by verifying your email." -msgstr "通過驗證電子郵件地址來保護你的帳號。" +msgstr "通過驗證電子郵件地址來保護您的帳號。" #: src/screens/Onboarding/StepFinished.tsx:107 msgid "Public" @@ -3451,7 +3372,7 @@ msgstr "公開且可共享的批量靜音或封鎖列表。" #: src/view/screens/Lists.tsx:61 msgid "Public, shareable lists which can drive feeds." -msgstr "公開且可共享的列表,可作為訊息流使用。" +msgstr "公開且可共享的列表,可作為動態源使用。" #: src/view/com/composer/Composer.tsx:385 msgid "Publish post" @@ -3487,14 +3408,6 @@ msgstr "比率" msgid "Recent Searches" msgstr "最近的搜尋結果" -#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:117 -#~ msgid "Recommended Feeds" -#~ msgstr "推薦訊息流" - -#: src/view/com/auth/onboarding/RecommendedFollows.tsx:181 -#~ msgid "Recommended Users" -#~ msgstr "推薦用戶" - #: src/components/dialogs/MutedWords.tsx:286 #: src/view/com/feeds/FeedSourceCard.tsx:283 #: src/view/com/modals/ListAddRemoveUsers.tsx:268 @@ -3514,26 +3427,26 @@ msgstr "刪除頭像" #: src/view/com/util/UserBanner.tsx:148 msgid "Remove Banner" -msgstr "刪除橫幅圖片" +msgstr "刪除橫幅" #: src/view/com/posts/FeedErrorMessage.tsx:160 msgid "Remove feed" -msgstr "刪除訊息流" +msgstr "刪除動態源" #: src/view/com/posts/FeedErrorMessage.tsx:201 msgid "Remove feed?" -msgstr "刪除訊息流?" +msgstr "刪除動態源?" #: src/view/com/feeds/FeedSourceCard.tsx:173 #: src/view/com/feeds/FeedSourceCard.tsx:233 #: src/view/screens/ProfileFeed.tsx:346 #: src/view/screens/ProfileFeed.tsx:352 msgid "Remove from my feeds" -msgstr "從我的訊息流中刪除" +msgstr "從我的動態源中刪除" #: src/view/com/feeds/FeedSourceCard.tsx:278 msgid "Remove from my feeds?" -msgstr "從我的訊息流中刪除?" +msgstr "從我的動態源中刪除?" #: src/view/com/composer/photos/Gallery.tsx:167 msgid "Remove image" @@ -3545,19 +3458,19 @@ msgstr "刪除圖片預覽" #: src/components/dialogs/MutedWords.tsx:329 msgid "Remove mute word from your list" -msgstr "從你的列表中移除靜音文字" +msgstr "從您的列表中移除靜音文字" #: src/view/com/util/post-embeds/QuoteEmbed.tsx:208 msgid "Remove quote" -msgstr "" +msgstr "刪除引用貼文" #: src/view/com/modals/Repost.tsx:48 msgid "Remove repost" -msgstr "刪除轉發" +msgstr "刪除轉貼貼文" #: src/view/com/posts/FeedErrorMessage.tsx:202 msgid "Remove this feed from your saved feeds" -msgstr "將這個訊息流從儲存的訊息流列表中刪除" +msgstr "將這個動態源從儲存的動態源列表中刪除" #: src/view/com/modals/ListAddRemoveUsers.tsx:199 #: src/view/com/modals/UserAddRemoveLists.tsx:152 @@ -3566,19 +3479,19 @@ msgstr "從列表中刪除" #: src/view/com/feeds/FeedSourceCard.tsx:121 msgid "Removed from my feeds" -msgstr "從我的訊息流中刪除" +msgstr "從我的動態源中刪除" #: src/view/screens/ProfileFeed.tsx:210 msgid "Removed from your feeds" -msgstr "從你的訊息流中刪除" +msgstr "從您的動態源中刪除" #: src/view/com/composer/ExternalEmbed.tsx:83 msgid "Removes default thumbnail from {0}" -msgstr "從 {0} 中刪除預設縮略圖" +msgstr "從 {0} 中刪除預設縮圖" #: src/view/com/util/post-embeds/QuoteEmbed.tsx:209 msgid "Removes quoted post" -msgstr "" +msgstr "刪除已轉貼貼文" #: src/view/screens/Profile.tsx:194 msgid "Replies" @@ -3586,7 +3499,7 @@ msgstr "回覆" #: src/view/com/threadgate/WhoCanReply.tsx:98 msgid "Replies to this thread are disabled" -msgstr "對此對話串的回覆已被停用" +msgstr "對此對話串的回覆已停用" #: src/view/com/composer/Composer.tsx:398 msgctxt "action" @@ -3601,7 +3514,7 @@ msgstr "回覆過濾器" #: src/view/com/posts/FeedItem.tsx:291 msgctxt "description" msgid "Reply to <0><1/>" -msgstr "回覆 <0><1/>" +msgstr "對 <0><1/> 回覆" #: src/view/com/profile/ProfileMenu.tsx:319 #: src/view/com/profile/ProfileMenu.tsx:322 @@ -3615,7 +3528,7 @@ msgstr "檢舉對話框" #: src/view/screens/ProfileFeed.tsx:363 #: src/view/screens/ProfileFeed.tsx:365 msgid "Report feed" -msgstr "檢舉訊息流" +msgstr "檢舉動態源" #: src/view/screens/ProfileList.tsx:431 msgid "Report List" @@ -3632,7 +3545,7 @@ msgstr "檢舉這個內容" #: src/components/ReportDialog/SelectReportOptionView.tsx:55 msgid "Report this feed" -msgstr "檢舉這個訊息流" +msgstr "檢舉這個動態源" #: src/components/ReportDialog/SelectReportOptionView.tsx:52 msgid "Report this list" @@ -3652,36 +3565,36 @@ msgstr "檢舉這個用戶" #: src/view/com/util/post-ctrls/RepostButton.tsx:61 msgctxt "action" msgid "Repost" -msgstr "轉發" +msgstr "轉貼" #: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 msgid "Repost" -msgstr "轉發" +msgstr "轉貼" #: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 #: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 msgid "Repost or quote post" -msgstr "轉發或引用貼文" +msgstr "轉貼或引用貼文" #: src/view/screens/PostRepostedBy.tsx:27 msgid "Reposted By" -msgstr "轉發" +msgstr "轉貼" #: src/view/com/posts/FeedItem.tsx:207 msgid "Reposted by {0}" -msgstr "由 {0} 轉發" +msgstr "由 {0} 轉貼" #: src/view/com/posts/FeedItem.tsx:224 msgid "Reposted by <0><1/>" -msgstr "由 <0><1/> 轉發" +msgstr "由 <0><1/> 轉貼" #: src/view/com/notifications/FeedItem.tsx:161 msgid "reposted your post" -msgstr "轉發你的貼文" +msgstr "轉貼您的貼文" #: src/view/com/post-thread/PostThreadItem.tsx:188 msgid "Reposts of this post" -msgstr "轉發這條貼文" +msgstr "轉貼這則貼文" #: src/view/com/modals/ChangeEmail.tsx:183 #: src/view/com/modals/ChangeEmail.tsx:185 @@ -3699,7 +3612,7 @@ msgstr "要求發佈前提供替代文字" #: src/view/screens/Settings/Email2FAToggle.tsx:54 msgid "Require email code to log into your account" -msgstr "需要電子郵件驗證碼才能登入你的帳戶" +msgstr "需要電子郵件驗證碼才能登入您的帳戶" #: src/screens/Signup/StepInfo/index.tsx:69 msgid "Required for this provider" @@ -3707,8 +3620,9 @@ msgstr "此供應商要求必填" #: src/view/screens/Settings/DisableEmail2FADialog.tsx:169 #: src/view/screens/Settings/DisableEmail2FADialog.tsx:172 +#UI 擁擠 msgid "Resend email" -msgstr "重新發送" +msgstr "重發 email" #: src/view/com/modals/ChangePassword.tsx:187 msgid "Reset code" @@ -3730,7 +3644,7 @@ msgstr "重設密碼" #: src/view/screens/Settings/index.tsx:807 #: src/view/screens/Settings/index.tsx:810 msgid "Reset preferences state" -msgstr "重設設定偏好狀態" +msgstr "重設偏好狀態" #: src/view/screens/Settings/index.tsx:818 msgid "Resets the onboarding state" @@ -3738,7 +3652,7 @@ msgstr "重設初始設定狀態" #: src/view/screens/Settings/index.tsx:808 msgid "Resets the preferences state" -msgstr "重設設定偏好狀態" +msgstr "重設偏好狀態" #: src/screens/Login/LoginForm.tsx:283 msgid "Retries login" @@ -3811,19 +3725,19 @@ msgstr "儲存圖片裁剪" #: src/view/screens/ProfileFeed.tsx:347 #: src/view/screens/ProfileFeed.tsx:353 msgid "Save to my feeds" -msgstr "儲存到我的訊息流" +msgstr "儲存到我的動態源" #: src/view/screens/SavedFeeds.tsx:123 msgid "Saved Feeds" -msgstr "已儲存訊息流" +msgstr "已儲存動態源" #: src/view/com/lightbox/Lightbox.tsx:81 msgid "Saved to your camera roll." -msgstr "儲存到你的相機膠卷。" +msgstr "儲存到您的圖片庫。" #: src/view/screens/ProfileFeed.tsx:214 msgid "Saved to your feeds" -msgstr "儲存到你的訊息流" +msgstr "儲存到您的動態源" #: src/view/com/modals/EditProfile.tsx:226 msgid "Saves any changes to your profile" @@ -3868,7 +3782,7 @@ msgstr "搜尋「{query}」" #: src/view/screens/Search/Search.tsx:839 msgid "Search for \"{searchText}\"" -msgstr "" +msgstr "搜尋「{searchText}」" #: src/components/TagMenu/index.tsx:145 msgid "Search for all posts by @{authorHandle} with tag {displayTag}" @@ -3955,7 +3869,7 @@ msgstr "選擇 {numItems} 個項目中的第 {i} 項" #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:52 msgid "Select some accounts below to follow" -msgstr "在下面選擇一些要跟隨的帳號" +msgstr "在下面選擇一些帳號來跟隨" #: src/components/ReportDialog/SubmitView.tsx:135 msgid "Select the moderation service(s) to report to" @@ -3963,43 +3877,43 @@ msgstr "選擇要檢舉的限制服務提供者" #: src/view/com/auth/server-input/index.tsx:82 msgid "Select the service that hosts your data." -msgstr "選擇用來託管你的資料的服務商。" +msgstr "選擇用來託管您的資料的服務商。" #: src/screens/Onboarding/StepTopicalFeeds.tsx:100 msgid "Select topical feeds to follow from the list below" -msgstr "從下面的列表中選擇要跟隨的主題訊息流" +msgstr "從下面的列表中選擇主題動態源來跟隨" #: src/screens/Onboarding/StepModeration/index.tsx:63 msgid "Select what you want to see (or not see), and we’ll handle the rest." -msgstr "選擇你想看到(或不想看到)的內容,剩下的由我們來處理。" +msgstr "選擇您想看到(或不想看到)的內容,剩下的由我們來處理。" #: src/view/screens/LanguageSettings.tsx:281 msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." -msgstr "選擇你希望訂閱訊息流中所包含的語言。未選擇任何語言時會預設顯示所有語言。" +msgstr "選擇您希望訂閱動態源中所包含的語言。未選擇任何語言時會預設顯示所有語言。" #: src/view/screens/LanguageSettings.tsx:98 msgid "Select your app language for the default text to display in the app." -msgstr "選擇你應用程式中的預設語言。" +msgstr "選擇應用程式中的預設語言。" #: src/screens/Signup/StepInfo/index.tsx:135 msgid "Select your date of birth" -msgstr "選擇你的出生日期" +msgstr "選擇您的出生日期" #: src/screens/Onboarding/StepInterests/index.tsx:201 msgid "Select your interests from the options below" -msgstr "下面選擇你感興趣的選項" +msgstr "從下面選擇您感興趣的選項" #: src/view/screens/LanguageSettings.tsx:190 msgid "Select your preferred language for translations in your feed." -msgstr "選擇你在訂閱訊息流中希望進行翻譯的目標語言偏好。" +msgstr "選擇您在動態源中翻譯的偏好的目標語言。" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:117 msgid "Select your primary algorithmic feeds" -msgstr "選擇你的訊息流主要算法" +msgstr "選擇您的動態的主要算法" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:133 msgid "Select your secondary algorithmic feeds" -msgstr "選擇你的訊息流次要算法" +msgstr "選擇您的動態的次要算法" #: src/view/com/modals/VerifyEmail.tsx:211 #: src/view/com/modals/VerifyEmail.tsx:213 @@ -4052,31 +3966,31 @@ msgstr "設定新密碼" #: src/view/screens/PreferencesFollowingFeed.tsx:225 msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." -msgstr "將此設定項設為「關」會隱藏來自訂閱訊息流的所有引用貼文。轉發仍將可見。" +msgstr "將此設定設為「關」以隱藏動態中所有引用的貼文,但轉貼依然會顯示。" #: src/view/screens/PreferencesFollowingFeed.tsx:122 msgid "Set this setting to \"No\" to hide all replies from your feed." -msgstr "將此設定項設為「關」以隱藏來自訂閱訊息流的所有回覆。" +msgstr "將此設定設為「關」以隱藏動態中所有回覆貼文。" #: src/view/screens/PreferencesFollowingFeed.tsx:191 msgid "Set this setting to \"No\" to hide all reposts from your feed." -msgstr "將此設定項設為「關」以隱藏來自訂閱訊息流的所有轉發。" +msgstr "將此設定設為「關」以隱藏動態的所有轉貼貼文。" #: src/view/screens/PreferencesThreads.tsx:122 msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." -msgstr "將此設定項設為「開」以在分層視圖中顯示回覆。這是一個實驗性功能。" +msgstr "將此設定項設為「開」以單頁顯示樹狀回覆,這是一項實驗性功能。" #: src/view/screens/PreferencesFollowingFeed.tsx:261 msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your Following feed. This is an experimental feature." -msgstr "將此設定為「是」以在你的追蹤訊息流中顯示你保存的訊息流。這是一個實驗性功能。" +msgstr "將此設定為「是」以在「Following 動態源」中顯示您追蹤的動態源中的選錄貼文,這是一個實驗性功能。" #: src/screens/Onboarding/Layout.tsx:48 msgid "Set up your account" -msgstr "設定你的帳號" +msgstr "設定您的帳號" #: src/view/com/modals/ChangeHandle.tsx:268 msgid "Sets Bluesky username" -msgstr "設定 Bluesky 用戶名稱" +msgstr "設定 Bluesky 帳號代碼" #: src/view/screens/Settings/index.tsx:436 msgid "Sets color theme to dark" @@ -4088,7 +4002,7 @@ msgstr "將色彩主題設定為亮色" #: src/view/screens/Settings/index.tsx:423 msgid "Sets color theme to system setting" -msgstr "將色彩主題設定為跟隨系統設定" +msgstr "將色彩主題設定為跟隨系統" #: src/view/screens/Settings/index.tsx:462 msgid "Sets dark theme to the dark theme" @@ -4096,7 +4010,7 @@ msgstr "將深色主題設定為深色" #: src/view/screens/Settings/index.tsx:455 msgid "Sets dark theme to the dim theme" -msgstr "將深色主題設定為暗淡" +msgstr "將深色主題設定為昏暗" #: src/screens/Login/ForgotPasswordForm.tsx:113 msgid "Sets email for password reset" @@ -4154,7 +4068,7 @@ msgstr "仍然分享" #: src/view/screens/ProfileFeed.tsx:373 #: src/view/screens/ProfileFeed.tsx:375 msgid "Share feed" -msgstr "分享訊息流" +msgstr "分享動態源" #: src/view/com/modals/LinkWarning.tsx:89 #: src/view/com/modals/LinkWarning.tsx:95 @@ -4163,7 +4077,7 @@ msgstr "分享連結" #: src/view/com/modals/LinkWarning.tsx:92 msgid "Shares the linked website" -msgstr "分享連結的網站" +msgstr "分享網站的連結" #: src/components/moderation/ContentHider.tsx:115 #: src/components/moderation/LabelPreference.tsx:136 @@ -4175,7 +4089,7 @@ msgstr "顯示" #: src/view/screens/PreferencesFollowingFeed.tsx:68 msgid "Show all replies" -msgstr "顯示所有回覆" +msgstr "顯示所有回覆貼文" #: src/components/moderation/ScreenHider.tsx:169 #: src/components/moderation/ScreenHider.tsx:172 @@ -4185,11 +4099,11 @@ msgstr "仍然顯示" #: src/lib/moderation/useLabelBehaviorDescription.ts:27 #: src/lib/moderation/useLabelBehaviorDescription.ts:63 msgid "Show badge" -msgstr "顯示徽章" +msgstr "顯示標記" #: src/lib/moderation/useLabelBehaviorDescription.ts:61 msgid "Show badge and filter from feeds" -msgstr "顯示徽章並從訊息流中篩選" +msgstr "顯示標記並從動態源中篩選" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:200 msgid "Show follows similar to {0}" @@ -4203,7 +4117,7 @@ msgstr "顯示更多" #: src/view/screens/PreferencesFollowingFeed.tsx:258 msgid "Show Posts from My Feeds" -msgstr "在自訂訊息流中顯示貼文" +msgstr "顯示來自已儲存動態源的貼文" #: src/view/screens/PreferencesFollowingFeed.tsx:222 msgid "Show Quote Posts" @@ -4211,15 +4125,15 @@ msgstr "顯示引用貼文" #: src/screens/Onboarding/StepFollowingFeed.tsx:119 msgid "Show quote-posts in Following feed" -msgstr "在跟隨訊息流中顯示引用" +msgstr "在訂閱的動態消息中顯示引用貼文" #: src/screens/Onboarding/StepFollowingFeed.tsx:135 msgid "Show quotes in Following" -msgstr "在跟隨中顯示引用" +msgstr "在跟隨中顯示引用貼文" #: src/screens/Onboarding/StepFollowingFeed.tsx:95 msgid "Show re-posts in Following feed" -msgstr "在跟隨訊息流中顯示轉發" +msgstr "在跟隨動態源中顯示轉貼貼文" #: src/view/screens/PreferencesFollowingFeed.tsx:119 msgid "Show Replies" @@ -4227,7 +4141,7 @@ msgstr "顯示回覆" #: src/view/screens/PreferencesThreads.tsx:100 msgid "Show replies by people you follow before all other replies." -msgstr "在所有其他回覆之前顯示你跟隨的人的回覆。" +msgstr "在所有其他回覆之前顯示您跟隨的人的回覆。" #: src/screens/Onboarding/StepFollowingFeed.tsx:87 msgid "Show replies in Following" @@ -4235,19 +4149,19 @@ msgstr "在跟隨中顯示回覆" #: src/screens/Onboarding/StepFollowingFeed.tsx:71 msgid "Show replies in Following feed" -msgstr "在跟隨訊息流中顯示回覆" +msgstr "在跟隨動態源中顯示回覆" #: src/view/screens/PreferencesFollowingFeed.tsx:70 msgid "Show replies with at least {value} {0}" -msgstr "顯示至少包含 {value} 個{0}的回覆" +msgstr "顯示至少包含 {value} 個 {0} 的回覆" #: src/view/screens/PreferencesFollowingFeed.tsx:188 msgid "Show Reposts" -msgstr "顯示轉發" +msgstr "顯示轉貼貼文" #: src/screens/Onboarding/StepFollowingFeed.tsx:111 msgid "Show reposts in Following" -msgstr "在跟隨中顯示轉發" +msgstr "在跟隨中顯示轉貼貼文" #: src/components/moderation/ContentHider.tsx:68 #: src/components/moderation/PostHider.tsx:73 @@ -4264,11 +4178,11 @@ msgstr "顯示警告" #: src/lib/moderation/useLabelBehaviorDescription.ts:56 msgid "Show warning and filter from feeds" -msgstr "顯示警告並從訊息流中篩選" +msgstr "顯示警告並從動態中篩選" #: src/view/com/post-thread/PostThreadFollowBtn.tsx:130 msgid "Shows posts from {0} in your feed" -msgstr "在你的訊息流中顯示來自 {0} 的貼文" +msgstr "在您的動態中顯示來自 {0} 的貼文" #: src/components/dialogs/Signin.tsx:97 #: src/components/dialogs/Signin.tsx:99 @@ -4301,7 +4215,7 @@ msgstr "登入為…" #: src/components/dialogs/Signin.tsx:75 msgid "Sign in or create your account to join the conversation!" -msgstr "登入或建立你的帳戶以加入對話!" +msgstr "登入或建立您的帳戶即可加入對話!" #: src/components/dialogs/Signin.tsx:46 msgid "Sign into Bluesky or create a new account" @@ -4326,7 +4240,7 @@ msgstr "註冊" #: src/view/shell/NavSignupCard.tsx:47 msgid "Sign up or sign in to join the conversation" -msgstr "註冊或登入以參與對話" +msgstr "註冊或登入即可參與對話" #: src/components/moderation/ScreenHider.tsx:97 #: src/lib/moderation/useGlobalLabelStrings.ts:28 @@ -4362,7 +4276,7 @@ msgstr "發生了一些問題,請重試。" #: src/App.native.tsx:64 msgid "Sorry! Your session expired. Please log in again." -msgstr "抱歉!你的登入已過期。請重新登入。" +msgstr "抱歉!您的登入會話已過期。請重新登入。" #: src/view/screens/PreferencesThreads.tsx:69 msgid "Sort Replies" @@ -4382,7 +4296,7 @@ msgstr "垃圾訊息" #: src/lib/moderation/useReportOptions.ts:53 msgid "Spam; excessive mentions or replies" -msgstr "垃圾訊息;過多的提及或回覆" +msgstr "垃圾訊息、過多的提及或回覆" #: src/screens/Onboarding/index.tsx:30 msgid "Sports" @@ -4398,11 +4312,11 @@ msgstr "狀態頁" #: src/screens/Signup/index.tsx:145 msgid "Step" -msgstr "Step" +msgstr "步驟" #: src/view/screens/Settings/index.tsx:288 msgid "Storage cleared, you need to restart the app now." -msgstr "已清除儲存資料,你需要立即重啟應用程式。" +msgstr "已清除儲存資料,您需要立即重啟應用程式。" #: src/Navigation.tsx:218 #: src/view/screens/Settings/index.tsx:790 @@ -4420,20 +4334,20 @@ msgstr "訂閱" #: src/screens/Profile/Sections/Labels.tsx:194 msgid "Subscribe to @{0} to use these labels:" -msgstr "訂閱 @{0} 以使用這些標籤:" +msgstr "訂閱 @{0} 以使用這些標記:" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:230 msgid "Subscribe to Labeler" -msgstr "訂閱標籤者" +msgstr "訂閱標記者" #: src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx:172 #: src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx:307 msgid "Subscribe to the {0} feed" -msgstr "訂閱 {0} 訊息流" +msgstr "訂閱 {0} 動態源" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:194 msgid "Subscribe to this labeler" -msgstr "訂閱這個標籤者" +msgstr "訂閱這個標記者" #: src/view/screens/ProfileList.tsx:588 msgid "Subscribe to this list" @@ -4445,11 +4359,11 @@ msgstr "推薦的跟隨者" #: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65 msgid "Suggested for you" -msgstr "為你推薦" +msgstr "為您推薦" #: src/view/com/modals/SelfLabel.tsx:95 msgid "Suggestive" -msgstr "建議" +msgstr "暗示" #: src/Navigation.tsx:233 #: src/view/screens/Support.tsx:30 @@ -4468,7 +4382,7 @@ msgstr "切換到 {0}" #: src/view/screens/Settings/index.tsx:144 msgid "Switches the account you are logged in to" -msgstr "切換你登入的帳號" +msgstr "切換您登入的帳號" #: src/view/screens/Settings/index.tsx:420 msgid "System" @@ -4526,7 +4440,7 @@ msgstr "文字輸入框" #: src/components/ReportDialog/SubmitView.tsx:77 msgid "Thank you. Your report has been sent." -msgstr "謝謝,你的檢舉已提交。" +msgstr "謝謝,您的檢舉已提交。" #: src/view/com/modals/ChangeHandle.tsx:466 msgid "That contains the following:" @@ -4539,7 +4453,7 @@ msgstr "這個帳號代碼已被使用。" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:282 #: src/view/com/profile/ProfileMenu.tsx:349 msgid "The account will be able to interact with you after unblocking." -msgstr "解除封鎖後,該帳號將能夠與你互動。" +msgstr "解除封鎖後,該帳號將能夠與您互動。" #: src/components/moderation/ModerationDetailsDialog.tsx:127 msgid "the author" @@ -4555,15 +4469,15 @@ msgstr "版權政策已移動到 <0/>" #: src/components/moderation/LabelsOnMeDialog.tsx:48 msgid "The following labels were applied to your account." -msgstr "以下標籤已套用到你的帳戶。" +msgstr "以下標記已套用到您的帳戶。" #: src/components/moderation/LabelsOnMeDialog.tsx:49 msgid "The following labels were applied to your content." -msgstr "以下標籤已套用到你的內容。" +msgstr "以下標記已套用到您的內容。" #: src/screens/Onboarding/Layout.tsx:58 msgid "The following steps will help customize your Bluesky experience." -msgstr "以下步驟將幫助自訂你的 Bluesky 體驗。" +msgstr "以下步驟將幫助自訂您的 Bluesky 體驗。" #: src/view/com/post-thread/PostThread.tsx:153 #: src/view/com/post-thread/PostThread.tsx:165 @@ -4584,24 +4498,24 @@ msgstr "服務條款已遷移到" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:141 msgid "There are many feeds to try:" -msgstr "這裡有些訊息流你可以嘗試:" +msgstr "這裡有些動態源您可以嘗試:" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:115 #: src/view/screens/ProfileFeed.tsx:556 msgid "There was an an issue contacting the server, please check your internet connection and try again." -msgstr "連線至伺服器時出現問題,請檢查你的網路連線並重試。" +msgstr "連線至伺服器時出現問題,請檢查您的網路連線並重試。" #: src/view/com/posts/FeedErrorMessage.tsx:138 msgid "There was an an issue removing this feed. Please check your internet connection and try again." -msgstr "刪除訊息流時出現問題,請檢查你的網路連線並重試。" +msgstr "刪除動態源時出現問題,請檢查您的網路連線並重試。" #: src/view/screens/ProfileFeed.tsx:219 msgid "There was an an issue updating your feeds, please check your internet connection and try again." -msgstr "更新訊息流時出現問題,請檢查你的網路連線並重試。" +msgstr "更新動態源時出現問題,請檢查您的網路連線並重試。" #: src/components/dialogs/GifSelect.tsx:201 msgid "There was an issue connecting to Tenor." -msgstr "連線 Tenor 時出現問題。" +msgstr "連線到 Tenor 時出現問題。" #: src/view/screens/ProfileFeed.tsx:247 #: src/view/screens/ProfileList.tsx:277 @@ -4635,11 +4549,11 @@ msgstr "取得列表時發生問題,點擊這裡重試。" #: src/components/ReportDialog/SubmitView.tsx:82 msgid "There was an issue sending your report. Please check your internet connection." -msgstr "提交你的檢舉時出現問題,請檢查你的網路連線。" +msgstr "提交您的檢舉時出現問題,請檢查您的網路連線。" #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:65 msgid "There was an issue syncing your preferences with the server" -msgstr "與伺服器同步設定偏好時發生問題" +msgstr "與伺服器同步偏好時發生問題" #: src/view/screens/AppPasswords.tsx:68 msgid "There was an issue with fetching your app passwords" @@ -4664,20 +4578,20 @@ msgstr "發生問題了!{0}" #: src/view/screens/ProfileList.tsx:318 #: src/view/screens/ProfileList.tsx:332 msgid "There was an issue. Please check your internet connection and try again." -msgstr "發生問題了。請檢查你的網路連線並重試。" +msgstr "發生問題了。請檢查您的網路連線並重試。" #: src/components/dialogs/GifSelect.tsx:289 #: src/view/com/util/ErrorBoundary.tsx:57 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" -msgstr "應用程式中發生了意外問題。請告訴我們是否發生在你身上!" +msgstr "應用程式中發生了意外問題。請告訴我們是否發生在您身上!" #: src/screens/Deactivated.tsx:113 msgid "There's been a rush of new users to Bluesky! We'll activate your account as soon as we can." -msgstr "Bluesky 迎來了大量新用戶!我們將儘快啟用你的帳號。" +msgstr "Bluesky 迎來了大量新用戶!我們將儘快啟用您的帳號。" #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:146 msgid "These are popular accounts you might like:" -msgstr "這裡是一些受歡迎的帳號,你可能會喜歡:" +msgstr "這裡是一些受歡迎的帳號,您可能會喜歡:" #: src/components/moderation/ScreenHider.tsx:116 msgid "This {screenDescription} has been flagged:" @@ -4693,7 +4607,7 @@ msgstr "此申訴將被提交至 <0>{0}。" #: src/lib/moderation/useGlobalLabelStrings.ts:19 msgid "This content has been hidden by the moderators." -msgstr "此內容已被限制提供者隱藏。" +msgstr "此內容已被限制者隱藏。" #: src/lib/moderation/useGlobalLabelStrings.ts:24 msgid "This content has received a general warning from moderators." @@ -4714,21 +4628,21 @@ msgstr "沒有 Bluesky 帳號,無法查看此內容。" #: src/view/screens/Settings/ExportCarDialog.tsx:76 msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost." -msgstr "此功能目前為測試版本。你可以在<0>這篇部落格文章中了解更多有關資訊。" +msgstr "此功能目前為測試版本。您可以在<0>這篇部落格文章中了解更多有關資訊。" #: src/view/com/posts/FeedErrorMessage.tsx:114 msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later." -msgstr "此訊息流由於目前使用人數眾多而暫時無法使用。請稍後再試。" +msgstr "此動態源由於目前使用人數眾多而暫時無法使用。請稍後再試。" #: src/screens/Profile/Sections/Feed.tsx:59 #: src/view/screens/ProfileFeed.tsx:488 #: src/view/screens/ProfileList.tsx:677 msgid "This feed is empty!" -msgstr "這個訊息流是空的!" +msgstr "這個動態源是空的!" #: src/view/com/posts/CustomFeedEmptyState.tsx:37 msgid "This feed is empty! You may need to follow more users or tune your language settings." -msgstr "這個訊息流是空的!你或許需要先跟隨更多的人或檢查你的語言設定。" +msgstr "這個動態源是空的!您或許需要先跟隨更多的人或檢查您的語言設定。" #: src/components/dialogs/BirthDateSettings.tsx:41 msgid "This information is not shared with other users." @@ -4736,19 +4650,19 @@ msgstr "此資訊不會分享給其他用戶。" #: src/view/com/modals/VerifyEmail.tsx:128 msgid "This is important in case you ever need to change your email or reset your password." -msgstr "這很重要,以防你將來需要更改電子郵件地址或重設密碼。" +msgstr "這很重要,以防您將來需要更改電子郵件地址或重設密碼。" #: src/components/moderation/ModerationDetailsDialog.tsx:124 msgid "This label was applied by {0}." -msgstr "此標籤是由 {0} 套用的。" +msgstr "此標記是由 {0} 套用的。" #: src/screens/Profile/Sections/Labels.tsx:181 msgid "This labeler hasn't declared what labels it publishes, and may not be active." -msgstr "此標籤者尚未宣告它發佈的標籤,可能不活躍。" +msgstr "此標記者尚未宣告它發佈的標記,而且可能不會生效。" #: src/view/com/modals/LinkWarning.tsx:72 msgid "This link is taking you to the following website:" -msgstr "此連結將帶你到以下網站:" +msgstr "此連結將帶您到以下網站:" #: src/view/screens/ProfileList.tsx:855 msgid "This list is empty!" @@ -4773,7 +4687,7 @@ msgstr "只有登入用戶能見到這則貼文,未登入的人將看不到它 #: src/view/com/util/forms/PostDropdownBtn.tsx:352 msgid "This post will be hidden from feeds." -msgstr "這則貼文將從訊息流中被隱藏。" +msgstr "這則貼文將從動態隱藏。" #: src/view/com/profile/ProfileMenu.tsx:370 msgid "This profile is only visible to logged-in users. It won't be visible to people who aren't logged in." @@ -4794,7 +4708,7 @@ msgstr "此用戶沒有任何追隨者。" #: src/components/moderation/ModerationDetailsDialog.tsx:72 #: src/lib/moderation/useModerationCauseDescription.ts:68 msgid "This user has blocked you. You cannot view their content." -msgstr "此用戶已封鎖你,你無法查看他們的內容。" +msgstr "此用戶已封鎖您,您無法查看他們的內容。" #: src/lib/moderation/useGlobalLabelStrings.ts:30 msgid "This user has requested that their content only be shown to signed-in users." @@ -4802,11 +4716,11 @@ msgstr "此用戶要求僅將其內容顯示給已登錄的用戶。" #: src/components/moderation/ModerationDetailsDialog.tsx:55 msgid "This user is included in the <0>{0} list which you have blocked." -msgstr "此用戶包含在你已封鎖的 <0>{0} 列表中。" +msgstr "此用戶包含在您已封鎖的 <0>{0} 列表中。" #: src/components/moderation/ModerationDetailsDialog.tsx:84 msgid "This user is included in the <0>{0} list which you have muted." -msgstr "此用戶包含在你已靜音的 <0>{0} 列表中。" +msgstr "此用戶包含在您已靜音的 <0>{0} 列表中。" #: src/view/com/profile/ProfileFollows.tsx:87 msgid "This user isn't following anyone." @@ -4818,7 +4732,7 @@ msgstr "此警告僅適用於附帶媒體的貼文。" #: src/components/dialogs/MutedWords.tsx:283 msgid "This will delete {0} from your muted words. You can always add it back later." -msgstr "這將從你的靜音文字中刪除 {0},你隨時可以在稍後添加回來。" +msgstr "這將從您的靜音文字中刪除 {0},您隨時可以在稍後添加回來。" #: src/view/screens/Settings/index.tsx:569 msgid "Thread preferences" @@ -4831,7 +4745,7 @@ msgstr "對話串偏好" #: src/view/screens/PreferencesThreads.tsx:119 msgid "Threaded Mode" -msgstr "對話串模式" +msgstr "樹狀顯示模式" #: src/Navigation.tsx:276 msgid "Threads Preferences" @@ -4839,11 +4753,11 @@ msgstr "對話串偏好" #: src/view/screens/Settings/DisableEmail2FADialog.tsx:103 msgid "To disable the email 2FA method, please verify your access to the email address." -msgstr "若要關閉電子郵件雙重驗證,請驗證你的電子郵件地址。" +msgstr "若要關閉電子郵件雙重驗證,請驗證您的電子郵件地址。" #: src/components/ReportDialog/SelectLabelerView.tsx:33 msgid "To whom would you like to send this report?" -msgstr "你希望向誰提交此檢舉?" +msgstr "您希望向誰提交此檢舉?" #: src/components/dialogs/MutedWords.tsx:112 msgid "Toggle between muted word options." @@ -4901,7 +4815,7 @@ msgstr "取消靜音列表" #: src/screens/Signup/index.tsx:65 #: src/view/com/modals/ChangePassword.tsx:72 msgid "Unable to contact your service. Please check your Internet connection." -msgstr "無法連線到服務,請檢查你的網路連線。" +msgstr "無法連線到服務,請檢查您的網路連線。" #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:181 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:286 @@ -4930,7 +4844,7 @@ msgstr "取消封鎖?" #: src/view/com/util/post-ctrls/RepostButton.tsx:60 #: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 msgid "Undo repost" -msgstr "取消轉發" +msgstr "取消轉貼" #: src/view/com/profile/FollowButton.tsx:60 msgctxt "action" @@ -4956,7 +4870,7 @@ msgstr "取消喜歡" #: src/view/screens/ProfileFeed.tsx:585 msgid "Unlike this feed" -msgstr "取消喜歡這個訊息流" +msgstr "取消喜歡這個動態源" #: src/components/TagMenu/index.tsx:249 #: src/view/screens/ProfileList.tsx:581 @@ -4984,15 +4898,15 @@ msgstr "取消靜音對話串" #: src/view/screens/ProfileFeed.tsx:306 #: src/view/screens/ProfileList.tsx:565 msgid "Unpin" -msgstr "取消固定" +msgstr "取消釘選" #: src/view/screens/ProfileFeed.tsx:303 msgid "Unpin from home" -msgstr "取消固定在首頁" +msgstr "取消釘選在首頁" #: src/view/screens/ProfileList.tsx:446 msgid "Unpin moderation list" -msgstr "取消固定限制列表" +msgstr "取消釘選限制列表" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:228 msgid "Unsubscribe" @@ -5000,11 +4914,11 @@ msgstr "取消訂閱" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:193 msgid "Unsubscribe from this labeler" -msgstr "取消訂閱這個標籤者" +msgstr "取消訂閱這個標記者" #: src/lib/moderation/useReportOptions.ts:70 msgid "Unwanted Sexual Content" -msgstr "無關情色內容" +msgstr "無關情色的內容" #: src/view/com/modals/UserAddRemoveLists.tsx:70 msgid "Update {displayName} in Lists" @@ -5039,15 +4953,15 @@ msgstr "從檔案上傳" #: src/view/com/util/UserBanner.tsx:127 #: src/view/com/util/UserBanner.tsx:131 msgid "Upload from Library" -msgstr "從圖庫上傳" +msgstr "從圖片庫上傳" #: src/view/com/modals/ChangeHandle.tsx:409 msgid "Use a file on your server" -msgstr "使用伺服器上的檔案" +msgstr "使用您伺服器上的檔案" #: src/view/screens/AppPasswords.tsx:197 msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." -msgstr "使用應用程式專用密碼登入到其他 Bluesky 客戶端,而無需提供你的帳號或密碼。" +msgstr "使用應用程式專用密碼登入到其他 Bluesky 客戶端,而無需提供完整的帳戶權限和密碼。" #: src/view/com/modals/ChangeHandle.tsx:518 msgid "Use bsky.social as hosting provider" @@ -5073,7 +4987,7 @@ msgstr "使用 DNS 控制台" #: src/view/com/modals/AddAppPasswords.tsx:156 msgid "Use this to sign into the other app along with your handle." -msgstr "使用這個和你的帳號代碼一起登入其他應用程式。" +msgstr "使用這個和您的帳號代碼一起登入其他應用程式。" #: src/view/com/modals/InviteCodes.tsx:201 msgid "Used by:" @@ -5086,7 +5000,7 @@ msgstr "用戶被封鎖" #: src/lib/moderation/useModerationCauseDescription.ts:48 msgid "User Blocked by \"{0}\"" -msgstr "用戶被\"{0}\"封鎖" +msgstr "用戶被「{0}」封鎖" #: src/components/moderation/ModerationDetailsDialog.tsx:53 msgid "User Blocked by List" @@ -5094,11 +5008,11 @@ msgstr "用戶被列表封鎖" #: src/lib/moderation/useModerationCauseDescription.ts:66 msgid "User Blocking You" -msgstr "用戶封鎖了你" +msgstr "用戶封鎖了您" #: src/components/moderation/ModerationDetailsDialog.tsx:70 msgid "User Blocks You" -msgstr "用戶封鎖了你" +msgstr "用戶封鎖了您" #: src/view/com/lists/ListCard.tsx:85 #: src/view/com/modals/UserAddRemoveLists.tsx:198 @@ -5113,7 +5027,7 @@ msgstr "<0/> 的用戶列表" #: src/view/com/modals/UserAddRemoveLists.tsx:196 #: src/view/screens/ProfileList.tsx:777 msgid "User list by you" -msgstr "你的用戶列表" +msgstr "您的用戶列表" #: src/view/com/modals/CreateOrEditList.tsx:198 msgid "User list created" @@ -5129,7 +5043,7 @@ msgstr "用戶列表" #: src/screens/Login/LoginForm.tsx:168 msgid "Username or email address" -msgstr "用戶名稱或電子郵件地址" +msgstr "帳號代碼或電子郵件地址" #: src/view/screens/ProfileList.tsx:813 msgid "Users" @@ -5174,7 +5088,7 @@ msgstr "驗證新的電子郵件" #: src/view/com/modals/VerifyEmail.tsx:112 msgid "Verify Your Email" -msgstr "驗證你的電子郵件" +msgstr "驗證您的電子郵件" #: src/view/screens/Settings/index.tsx:852 msgid "Version {0}" @@ -5190,7 +5104,7 @@ msgstr "查看{0}的頭貼" #: src/view/screens/Log.tsx:52 msgid "View debug entry" -msgstr "查看除錯項目" +msgstr "查看偵錯項目" #: src/components/ReportDialog/SelectReportOptionView.tsx:132 msgid "View details" @@ -5206,7 +5120,7 @@ msgstr "查看整個對話串" #: src/components/moderation/LabelsOnMe.tsx:51 msgid "View information about these labels" -msgstr "查看有關這些標籤的資訊" +msgstr "查看有關這些標記的資訊" #: src/components/ProfileHoverCard/index.web.tsx:387 #: src/components/ProfileHoverCard/index.web.tsx:416 @@ -5224,7 +5138,7 @@ msgstr "查看由 @{0} 提供的標籤服務" #: src/view/screens/ProfileFeed.tsx:597 msgid "View users who like this feed" -msgstr "查看喜歡此訊息流的用戶" +msgstr "查看喜歡此動態源的用戶" #: src/view/com/modals/LinkWarning.tsx:89 #: src/view/com/modals/LinkWarning.tsx:95 @@ -5244,7 +5158,7 @@ msgstr "警告內容" #: src/lib/moderation/useLabelBehaviorDescription.ts:46 msgid "Warn content and filter from feeds" -msgstr "警告內容並從訊息流中過濾" +msgstr "警告內容並從動態源中過濾" #: src/screens/Hashtag.tsx:210 msgid "We couldn't find any results for that hashtag." @@ -5252,47 +5166,47 @@ msgstr "我們找不到任何與該標籤相關的結果。" #: src/screens/Deactivated.tsx:140 msgid "We estimate {estimatedTime} until your account is ready." -msgstr "我們估計還需要 {estimatedTime} 才能準備好你的帳號。" +msgstr "我們估計還需要 {estimatedTime} 才能準備好您的帳號。" #: src/screens/Onboarding/StepFinished.tsx:99 msgid "We hope you have a wonderful time. Remember, Bluesky is:" -msgstr "我們希望你在此度過愉快的時光。請記住,Bluesky 是:" +msgstr "我們希望您在此度過愉快的時光。請記住,Bluesky 是:" #: src/view/com/posts/DiscoverFallbackHeader.tsx:29 msgid "We ran out of posts from your follows. Here's the latest from <0/>." -msgstr "你已看完了你跟隨的貼文。這是 <0/> 的最新貼文。" +msgstr "您已看完了您跟隨的貼文。這是來自 <0/> 的最新貼文。" #: src/components/dialogs/MutedWords.tsx:203 msgid "We recommend avoiding common words that appear in many posts, since it can result in no posts being shown." -msgstr "我們建議避免新增在許多貼文中常用的文字,因為這可能令你看不到任何貼文。" +msgstr "我們建議避免新增在許多貼文中常用的文字,因為這可能令您看不到任何貼文。" #: src/screens/Onboarding/StepAlgoFeeds/index.tsx:125 msgid "We recommend our \"Discover\" feed:" -msgstr "我們推薦我們的「Discover」訊息流:" +msgstr "我們推薦我們的「Discover」動態源:" #: src/components/dialogs/BirthDateSettings.tsx:52 msgid "We were unable to load your birth date preferences. Please try again." -msgstr "我們無法載入你的出生日期設定偏好,請再試一次。" +msgstr "我們無法載入您的出生日期偏好,請再試一次。" #: src/screens/Moderation/index.tsx:385 msgid "We were unable to load your configured labelers at this time." -msgstr "我們目前無法載入你已設定的標籤者。" +msgstr "我們目前無法載入您已設定的標籤者。" #: src/screens/Onboarding/StepInterests/index.tsx:138 msgid "We weren't able to connect. Please try again to continue setting up your account. If it continues to fail, you can skip this flow." -msgstr "我們無法連線到網際網路,請重試以繼續設定你的帳號。如果仍繼續失敗,你可以選擇跳過此流程。" +msgstr "我們無法連線到網際網路,請重試以繼續設定您的帳號。如果仍繼續失敗,您可以選擇跳過此流程。" #: src/screens/Deactivated.tsx:144 msgid "We will let you know when your account is ready." -msgstr "我們會在你的帳號準備好時通知你。" +msgstr "我們會在您的帳號準備好時通知您。" #: src/screens/Onboarding/StepInterests/index.tsx:143 msgid "We'll use this to help customize your experience." -msgstr "我們將使用這些資訊來幫助定制你的體驗。" +msgstr "我們將使用這些資訊來幫助定制您的體驗。" #: src/screens/Signup/index.tsx:133 msgid "We're so excited to have you join us!" -msgstr "我們非常高興你加入我們!" +msgstr "我們非常高興您加入我們!" #: src/view/screens/ProfileList.tsx:90 msgid "We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @{handleOrDid}." @@ -5300,28 +5214,24 @@ msgstr "很抱歉,我們無法解析此列表。如果問題持續發生,請 #: src/components/dialogs/MutedWords.tsx:229 msgid "We're sorry, but we weren't able to load your muted words at this time. Please try again." -msgstr "很抱歉,我們目前無法載入你的靜音文字。請稍後再試。" +msgstr "很抱歉,我們目前無法載入您的靜音文字。請稍後再試。" #: src/view/screens/Search/Search.tsx:262 msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." -msgstr "很抱歉,無法完成你的搜尋請求。請稍後再試。" +msgstr "很抱歉,無法完成您的搜尋請求。請稍後再試。" #: src/components/Lists.tsx:197 #: src/view/screens/NotFound.tsx:48 msgid "We're sorry! We can't find the page you were looking for." -msgstr "很抱歉!我們找不到你正在尋找的頁面。" +msgstr "很抱歉!我們找不到您正在尋找的頁面。" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:330 msgid "We're sorry! You can only subscribe to ten labelers, and you've reached your limit of ten." -msgstr "抱歉!你只能訂閱十個標籤者,你已達到十個的限制。" - -#: src/view/com/auth/onboarding/WelcomeMobile.tsx:48 -#~ msgid "Welcome to <0>Bluesky" -#~ msgstr "歡迎來到 <0>Bluesky" +msgstr "抱歉!您只能訂閱十個標籤者,您已達到十個的限制。" #: src/screens/Onboarding/StepInterests/index.tsx:135 msgid "What are your interests?" -msgstr "你感興趣的是什麼?" +msgstr "您感興趣的是什麼?" #: src/view/com/auth/SplashScreen.tsx:40 #: src/view/com/auth/SplashScreen.web.tsx:81 @@ -5335,7 +5245,7 @@ msgstr "這個貼文使用了哪些語言?" #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 msgid "Which languages would you like to see in your algorithmic feeds?" -msgstr "你想在演算法訊息流中看到哪些語言?" +msgstr "您想在演算法動態源中看到哪些語言?" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:47 #: src/view/com/modals/Threadgate.tsx:66 @@ -5348,7 +5258,7 @@ msgstr "為什麼應該審查這個內容?" #: src/components/ReportDialog/SelectReportOptionView.tsx:56 msgid "Why should this feed be reviewed?" -msgstr "為什麼應該審查這個訊息流?" +msgstr "為什麼應該審查這個動態源?" #: src/components/ReportDialog/SelectReportOptionView.tsx:53 msgid "Why should this list be reviewed?" @@ -5373,7 +5283,7 @@ msgstr "撰寫貼文" #: src/view/com/composer/Composer.tsx:306 #: src/view/com/composer/Prompt.tsx:37 msgid "Write your reply" -msgstr "撰寫你的回覆" +msgstr "撰寫您的回覆" #: src/screens/Onboarding/index.tsx:28 msgid "Writers" @@ -5391,150 +5301,150 @@ msgstr "開" #: src/screens/Deactivated.tsx:137 msgid "You are in line." -msgstr "輪到你了。" +msgstr "輪到您了。" #: src/view/com/profile/ProfileFollows.tsx:86 msgid "You are not following anyone." -msgstr "你沒有跟隨任何人。" +msgstr "您沒有跟隨任何人。" #: src/view/com/posts/FollowingEmptyState.tsx:67 #: src/view/com/posts/FollowingEndOfFeed.tsx:68 msgid "You can also discover new Custom Feeds to follow." -msgstr "你也可以探索並跟隨新的自訂訊息流。" +msgstr "您也可以探索並跟隨新的自訂動態源。" #: src/screens/Onboarding/StepFollowingFeed.tsx:143 msgid "You can change these settings later." -msgstr "你可以稍後在設定中更改。" +msgstr "您可以往後在設定中更改。" #: src/screens/Login/index.tsx:158 #: src/screens/Login/PasswordUpdatedForm.tsx:33 msgid "You can now sign in with your new password." -msgstr "你現在可以使用新密碼登入。" +msgstr "您現在可以使用新密碼登入。" #: src/view/com/profile/ProfileFollowers.tsx:86 msgid "You do not have any followers." -msgstr "你沒有任何跟隨者。" +msgstr "您沒有任何跟隨者。" #: src/view/com/modals/InviteCodes.tsx:67 msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." -msgstr "你目前還沒有邀請碼!當你持續使用 Bluesky 一段時間後,我們將提供一些新的邀請碼給你。" +msgstr "您目前還沒有邀請碼!當您持續使用 Bluesky 一段時間後,我們將提供一些新的邀請碼給您。" #: src/view/screens/SavedFeeds.tsx:103 msgid "You don't have any pinned feeds." -msgstr "你目前還沒有任何固定的訊息流。" +msgstr "您目前還沒有任何釘選的動態源。" #: src/view/screens/Feeds.tsx:477 msgid "You don't have any saved feeds!" -msgstr "你目前還沒有任何儲存的訊息流!" +msgstr "您目前還沒有任何儲存的動態源!" #: src/view/screens/SavedFeeds.tsx:136 msgid "You don't have any saved feeds." -msgstr "你目前還沒有任何儲存的訊息流。" +msgstr "您目前還沒有任何儲存的動態源。" #: src/view/com/post-thread/PostThread.tsx:159 msgid "You have blocked the author or you have been blocked by the author." -msgstr "你已封鎖該作者,或你已被該作者封鎖。" +msgstr "您已封鎖該作者,或您已被該作者封鎖。" #: src/components/moderation/ModerationDetailsDialog.tsx:66 #: src/lib/moderation/useModerationCauseDescription.ts:50 #: src/lib/moderation/useModerationCauseDescription.ts:58 msgid "You have blocked this user. You cannot view their content." -msgstr "你已封鎖了此用戶,你將無法查看他們發佈的內容。" +msgstr "您已封鎖了此用戶,您將無法查看他們發佈的內容。" #: src/screens/Login/SetNewPasswordForm.tsx:54 #: src/screens/Login/SetNewPasswordForm.tsx:91 #: src/view/com/modals/ChangePassword.tsx:89 #: src/view/com/modals/ChangePassword.tsx:123 msgid "You have entered an invalid code. It should look like XXXXX-XXXXX." -msgstr "你輸入的邀請碼無效。它應該長得像這樣 XXXXX-XXXXX。" +msgstr "您輸入的邀請碼無效。它應該長得像這樣 XXXXX-XXXXX。" #: src/lib/moderation/useModerationCauseDescription.ts:109 msgid "You have hidden this post" -msgstr "你已隱藏這則貼文" +msgstr "您已隱藏這則貼文" #: src/components/moderation/ModerationDetailsDialog.tsx:101 msgid "You have hidden this post." -msgstr "你已隱藏這則貼文。" +msgstr "您已隱藏這則貼文。" #: src/components/moderation/ModerationDetailsDialog.tsx:94 #: src/lib/moderation/useModerationCauseDescription.ts:92 msgid "You have muted this account." -msgstr "你已隱藏這個帳號。" +msgstr "您已隱藏這個帳號。" #: src/lib/moderation/useModerationCauseDescription.ts:86 msgid "You have muted this user" -msgstr "你已靜音這個用戶" +msgstr "您已靜音這個用戶" #: src/view/com/feeds/ProfileFeedgens.tsx:144 msgid "You have no feeds." -msgstr "你沒有訂閱訊息流。" +msgstr "您沒有訂閱動態源。" #: src/view/com/lists/MyLists.tsx:89 #: src/view/com/lists/ProfileLists.tsx:148 msgid "You have no lists." -msgstr "你沒有列表。" +msgstr "您沒有列表。" #: src/screens/Messages/List/index.tsx:92 msgid "You have no messages yet. Start a conversation with someone!" -msgstr "" +msgstr "您還沒有訊息。開始與其他人對話!" #: src/view/screens/ModerationBlockedAccounts.tsx:137 msgid "You have not blocked any accounts yet. To block an account, go to their profile and select \"Block account\" from the menu on their account." -msgstr "你還沒有封鎖任何帳號。要封鎖帳號,請轉到其個人資料並在其帳號上的選單中選擇「封鎖帳號」。" +msgstr "您還沒有封鎖任何帳號。要封鎖帳號,請轉到其個人資料並在其帳號上的選單中選擇「封鎖帳號」。" #: src/view/screens/AppPasswords.tsx:89 msgid "You have not created any app passwords yet. You can create one by pressing the button below." -msgstr "你還沒有建立任何應用程式專用密碼,如你想建立一個,按下面的按鈕。" +msgstr "您還沒有建立任何應用程式專用密碼,如您想建立一個,按下面的按鈕。" #: src/view/screens/ModerationMutedAccounts.tsx:136 msgid "You have not muted any accounts yet. To mute an account, go to their profile and select \"Mute account\" from the menu on their account." -msgstr "你還沒有靜音任何帳號。要靜音帳號,請轉到其個人資料並在其帳號上的選單中選擇「靜音帳號」。" +msgstr "您還沒有靜音任何帳號。要靜音帳號,請轉到其個人資料並在其帳號上的選單中選擇「靜音帳號」。" #: src/components/dialogs/MutedWords.tsx:249 msgid "You haven't muted any words or tags yet" -msgstr "你還沒有隱藏任何文字或標籤" +msgstr "您還沒有隱藏任何文字或標籤" #: src/components/moderation/LabelsOnMeDialog.tsx:68 msgid "You may appeal these labels if you feel they were placed in error." -msgstr "如果你覺得這些標籤是錯誤的,你可以申訴這些標籤。" +msgstr "如果您覺得這些標籤是錯誤的,您可以申訴這些標籤。" #: src/screens/Signup/StepInfo/Policies.tsx:79 msgid "You must be 13 years of age or older to sign up." -msgstr "你必須年滿 13 歲才能註冊。" +msgstr "您必須年滿 13 歲才能註冊。" #: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:110 msgid "You must be 18 years or older to enable adult content" -msgstr "你必須年滿 18 歲才能啟用成人內容" +msgstr "您必須年滿 18 歲才能啟用成人內容" #: src/components/ReportDialog/SubmitView.tsx:205 msgid "You must select at least one labeler for a report" -msgstr "你必須選擇至少一個標籤者來提交檢舉" +msgstr "您必須選擇至少一個標記者來提交檢舉" #: src/view/com/util/forms/PostDropdownBtn.tsx:150 msgid "You will no longer receive notifications for this thread" -msgstr "你將不再收到這條對話串的通知" +msgstr "您將不再收到這條對話串的通知" #: src/view/com/util/forms/PostDropdownBtn.tsx:153 msgid "You will now receive notifications for this thread" -msgstr "你將收到這條對話串的通知" +msgstr "您將收到這條對話串的通知" #: src/screens/Login/SetNewPasswordForm.tsx:104 msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." -msgstr "你將收到一封包含重設碼的電子郵件。請在此輸入該重設碼,然後輸入你的新密碼。" +msgstr "您將收到一封包含重設碼的電子郵件。請在此輸入該「重設碼」,然後輸入您的新密碼。" #: src/screens/Onboarding/StepModeration/index.tsx:60 msgid "You're in control" -msgstr "你盡在掌控" +msgstr "盡在您的掌控" #: src/screens/Deactivated.tsx:94 #: src/screens/Deactivated.tsx:95 #: src/screens/Deactivated.tsx:110 msgid "You're in line" -msgstr "輪到你了" +msgstr "輪到您了" #: src/screens/Onboarding/StepFinished.tsx:96 msgid "You're ready to go!" -msgstr "你已設定完成!" +msgstr "您已設定完成!" #: src/components/moderation/ModerationDetailsDialog.tsx:98 #: src/lib/moderation/useModerationCauseDescription.ts:101 @@ -5543,82 +5453,82 @@ msgstr "您選擇在這則貼文中隱藏文字或標籤。" #: src/view/com/posts/FollowingEndOfFeed.tsx:48 msgid "You've reached the end of your feed! Find some more accounts to follow." -msgstr "你已經瀏覽完你的訂閱訊息流啦!跟隨其他帳號吧。" +msgstr "您已經瀏覽完您的訂閱動態源啦!跟隨其他帳號吧。" #: src/screens/Signup/index.tsx:153 msgid "Your account" -msgstr "你的帳號" +msgstr "您的帳號" #: src/view/com/modals/DeleteAccount.tsx:69 msgid "Your account has been deleted" -msgstr "你的帳號已刪除" +msgstr "您的帳號已刪除" #: src/view/screens/Settings/ExportCarDialog.tsx:48 msgid "Your account repository, containing all public data records, can be downloaded as a \"CAR\" file. This file does not include media embeds, such as images, or your private data, which must be fetched separately." -msgstr "你可以將你的帳號存放庫下載為一個「CAR」檔案。該檔案包含了所有公開的資料紀錄,但不包括嵌入媒體,例如圖片或你的私人資料,目前這些資料必須另外擷取。" +msgstr "您可以將您的帳號存放庫下載為一個「CAR」檔案。該檔案包含了所有公開的資料紀錄,但不包括嵌入媒體,例如圖片或您的私人資料,目前這些資料必須另外擷取。" #: src/screens/Signup/StepInfo/index.tsx:123 msgid "Your birth date" -msgstr "你的生日" +msgstr "您的生日" #: src/view/com/modals/InAppBrowserConsent.tsx:47 msgid "Your choice will be saved, but can be changed later in settings." -msgstr "你的選擇將被儲存,但可以稍後在設定中更改。" +msgstr "您的選擇將被儲存,但可以稍後在設定中更改。" #: src/screens/Onboarding/StepFollowingFeed.tsx:62 msgid "Your default feed is \"Following\"" -msgstr "你的預設訊息流為「跟隨」" +msgstr "您的預設動態源為「Following」" #: src/screens/Login/ForgotPasswordForm.tsx:57 #: src/screens/Signup/state.ts:227 #: src/view/com/modals/ChangePassword.tsx:56 msgid "Your email appears to be invalid." -msgstr "你的電子郵件地址似乎無效。" +msgstr "您的電子郵件地址似乎無效。" #: src/view/com/modals/ChangeEmail.tsx:127 msgid "Your email has been updated but not verified. As a next step, please verify your new email." -msgstr "你的電子郵件地址已更新但尚未驗證。作為下一步,請驗證你的新電子郵件地址。" +msgstr "您的電子郵件地址已更新但尚未驗證。作為下一步,請驗證您的新電子郵件地址。" #: src/view/com/modals/VerifyEmail.tsx:123 msgid "Your email has not yet been verified. This is an important security step which we recommend." -msgstr "你的電子郵件地址尚未驗證。這是一個我們建議的重要安全步驟。" +msgstr "您的電子郵件地址尚未驗證。這是一個我們建議的重要安全步驟。" #: src/view/com/posts/FollowingEmptyState.tsx:47 msgid "Your following feed is empty! Follow more users to see what's happening." -msgstr "你的跟隨訊息流是空的!跟隨更多用戶來看看發生了什麼事情。" +msgstr "您的跟隨動態源是空的!跟隨更多用戶來看看發生了什麼事情。" #: src/screens/Signup/StepHandle.tsx:73 msgid "Your full handle will be" -msgstr "你的完整帳號代碼將修改為" +msgstr "您的完整帳號代碼將修改為" #: src/view/com/modals/ChangeHandle.tsx:272 msgid "Your full handle will be <0>@{0}" -msgstr "你的完整帳號代碼將修改為 <0>@{0}" +msgstr "您的完整帳號代碼將修改為 <0>@{0}" #: src/components/dialogs/MutedWords.tsx:220 msgid "Your muted words" -msgstr "你的靜音文字" +msgstr "您的靜音文字" #: src/view/com/modals/ChangePassword.tsx:159 msgid "Your password has been changed successfully!" -msgstr "你的密碼已成功更改!" +msgstr "您的密碼已成功更改!" #: src/view/com/composer/Composer.tsx:295 msgid "Your post has been published" -msgstr "你的貼文已發佈" +msgstr "您的貼文已發佈" #: src/screens/Onboarding/StepFinished.tsx:111 msgid "Your posts, likes, and blocks are public. Mutes are private." -msgstr "你的貼文、按喜歡和封鎖是公開可見的,而靜音是私人的。" +msgstr "您的貼文、按喜歡和封鎖是公開可見的,而靜音是私人的。" #: src/view/screens/Settings/index.tsx:129 msgid "Your profile" -msgstr "你的個人資料" +msgstr "您的個人資料" #: src/view/com/composer/Composer.tsx:294 msgid "Your reply has been published" -msgstr "你的回覆已發佈" +msgstr "您的回覆已發佈" #: src/screens/Signup/index.tsx:155 msgid "Your user handle" -msgstr "你的帳號代碼" +msgstr "您的帳號代碼" \ No newline at end of file diff --git a/src/logger/debugContext.ts b/src/logger/debugContext.ts index 0e04752e3f..9971207866 100644 --- a/src/logger/debugContext.ts +++ b/src/logger/debugContext.ts @@ -9,4 +9,5 @@ export const DebugContext = { // e.g. composer: 'composer' session: 'session', notifications: 'notifications', + convo: 'convo', } as const diff --git a/src/routes.ts b/src/routes.ts index 1a9b344d87..6845cccd0f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -37,7 +37,7 @@ export const router = new Router({ CommunityGuidelines: '/support/community-guidelines', CopyrightPolicy: '/support/copyright', Hashtag: '/hashtag/:tag', - MessagesList: '/messages', + Messages: '/messages', MessagesSettings: '/messages/settings', MessagesConversation: '/messages/:conversation', }) diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx index 134411903d..b02b8e1625 100644 --- a/src/screens/Login/ChooseAccountForm.tsx +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {SessionAccount, useSession, useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import * as Toast from '#/view/com/util/Toast' @@ -21,6 +22,7 @@ export const ChooseAccountForm = ({ onSelectAccount: (account?: SessionAccount) => void onPressBack: () => void }) => { + const [pendingDid, setPendingDid] = React.useState(null) const {track, screen} = useAnalytics() const {_} = useLingui() const {currentAccount} = useSession() @@ -33,26 +35,48 @@ export const ChooseAccountForm = ({ const onSelect = React.useCallback( async (account: SessionAccount) => { - if (account.accessJwt) { - if (account.did === currentAccount?.did) { - setShowLoggedOut(false) - Toast.show(_(msg`Already signed in as @${account.handle}`)) - } else { - await initSession(account) - logEvent('account:loggedIn', { - logContext: 'ChooseAccountForm', - withPassword: false, - }) - track('Sign In', {resumedSession: true}) - setTimeout(() => { - Toast.show(_(msg`Signed in as @${account.handle}`)) - }, 100) - } - } else { + if (pendingDid) { + // The session API isn't resilient to race conditions so let's just ignore this. + return + } + if (!account.accessJwt) { + // Move to login form. + onSelectAccount(account) + return + } + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(_(msg`Already signed in as @${account.handle}`)) + return + } + try { + setPendingDid(account.did) + await initSession(account) + logEvent('account:loggedIn', { + logContext: 'ChooseAccountForm', + withPassword: false, + }) + track('Sign In', {resumedSession: true}) + Toast.show(_(msg`Signed in as @${account.handle}`)) + } catch (e: any) { + logger.error('choose account: initSession failed', { + message: e.message, + }) + // Move to login form. onSelectAccount(account) + } finally { + setPendingDid(null) } }, - [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], + [ + currentAccount, + track, + initSession, + pendingDid, + onSelectAccount, + setShowLoggedOut, + _, + ], ) return ( @@ -66,6 +90,7 @@ export const ChooseAccountForm = ({ onSelectAccount()} + pendingDid={pendingDid} /> diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx new file mode 100644 index 0000000000..3de15e661d --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { + Dimensions, + Keyboard, + NativeSyntheticEvent, + Pressable, + TextInput, + TextInputContentSizeChangeEventData, + View, +} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_10} from '#/lib/constants' +import {useHaptics} from 'lib/haptics' +import {atoms as a, useTheme} from '#/alf' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' + +export function MessageInput({ + onSendMessage, + scrollToEnd, +}: { + onSendMessage: (message: string) => void + scrollToEnd: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const [message, setMessage] = React.useState('') + const [maxHeight, setMaxHeight] = React.useState() + const [isInputScrollable, setIsInputScrollable] = React.useState(false) + + const {top: topInset} = useSafeAreaInsets() + + const inputRef = React.useRef(null) + + const onSubmit = React.useCallback(() => { + if (message.trim() === '') { + return + } + onSendMessage(message.trimEnd()) + playHaptic() + setMessage('') + setTimeout(() => { + inputRef.current?.focus() + }, 100) + }, [message, onSendMessage, playHaptic]) + + const onInputLayout = React.useCallback( + (e: NativeSyntheticEvent) => { + const keyboardHeight = Keyboard.metrics()?.height ?? 0 + const windowHeight = Dimensions.get('window').height + + const max = windowHeight - keyboardHeight - topInset - 100 + const availableSpace = max - e.nativeEvent.contentSize.height + + setMaxHeight(max) + setIsInputScrollable(availableSpace < 30) + + scrollToEnd() + }, + [scrollToEnd, topInset], + ) + + return ( + + + + + + + + + ) +} diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx new file mode 100644 index 0000000000..a2f255bdc1 --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import TextareaAutosize from 'react-textarea-autosize' + +import {atoms as a, useTheme} from '#/alf' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' + +export function MessageInput({ + onSendMessage, +}: { + onSendMessage: (message: string) => void + scrollToEnd: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [message, setMessage] = React.useState('') + + const onSubmit = React.useCallback(() => { + if (message.trim() === '') { + return + } + onSendMessage(message.trimEnd()) + setMessage('') + }, [message, onSendMessage]) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (e.shiftKey) return + e.preventDefault() + onSubmit() + } + }, + [onSubmit], + ) + + const onChange = React.useCallback( + (e: React.ChangeEvent) => { + setMessage(e.target.value) + }, + [], + ) + + return ( + + + + + + + + + ) +} diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx new file mode 100644 index 0000000000..523788d4da --- /dev/null +++ b/src/screens/Messages/Conversation/MessageListError.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ConvoItem, ConvoItemError} from '#/state/messages/convo' +import {atoms as a, useTheme} from '#/alf' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function MessageListError({ + item, +}: { + item: ConvoItem & {type: 'error-recoverable'} +}) { + const t = useTheme() + const {_} = useLingui() + const message = React.useMemo(() => { + return { + [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), + [ConvoItemError.ResumeFailed]: _( + msg`There was an issue connecting to the chat.`, + ), + [ConvoItemError.PollFailed]: _( + msg`This chat was disconnected due to a network error.`, + ), + }[item.code] + }, [_, item.code]) + + return ( + + + + + {message}{' '} + { + e.preventDefault() + item.retry() + return false + }}> + {_(msg`Retry.`)} + + + + + ) +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx new file mode 100644 index 0000000000..1dc26d6c3a --- /dev/null +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -0,0 +1,258 @@ +import React, {useCallback, useRef} from 'react' +import {FlatList, View} from 'react-native' +import { + KeyboardAvoidingView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import {runOnJS, useSharedValue} from 'react-native-reanimated' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isIOS} from '#/platform/detection' +import {useChat} from '#/state/messages' +import {ConvoItem, ConvoStatus} from '#/state/messages/convo' +import {ScrollProvider} from 'lib/ScrollContext' +import {isWeb} from 'platform/detection' +import {List} from 'view/com/util/List' +import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' +import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {MessageItem} from '#/components/dms/MessageItem' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +function MaybeLoader({isLoading}: {isLoading: boolean}) { + return ( + + {isLoading && } + + ) +} + +function RetryButton({onPress}: {onPress: () => unknown}) { + const {_} = useLingui() + + return ( + + + + ) +} + +function renderItem({item}: {item: ConvoItem}) { + if (item.type === 'message' || item.type === 'pending-message') { + return ( + + ) + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'pending-retry') { + return + } else if (item.type === 'error-recoverable') { + return + } + + return null +} + +function keyExtractor(item: ConvoItem) { + return item.key +} + +function onScrollToIndexFailed() { + // Placeholder function. You have to give FlatList something or else it will error. +} + +export function MessagesList() { + const chat = useChat() + const flatListRef = useRef(null) + + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items + // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to + // the bottom. + const isAtBottom = useSharedValue(true) + + // This will be used on web to assist in determing if we need to maintain the content offset + const isAtTop = useSharedValue(true) + + // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing + // onStartReached to fire. + const contentHeight = useSharedValue(0) + + // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank + // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. + const isMomentumScrolling = useSharedValue(false) + + const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false) + + // Every time the content size changes, that means one of two things is happening: + // 1. New messages are being added from the log or from a message you have sent + // 2. Old messages are being prepended to the top + // + // The first time that the content size changes is when the initial items are rendered. Because we cannot rely on + // `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated. + // + // Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of + // the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However + // we will not scroll whenever new items get prepended to the top. + const onContentSizeChange = useCallback( + (_: number, height: number) => { + // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the + // previous offset whenever we add new content to the previous offset whenever we add new content to the list. + if (isWeb && isAtTop.value && hasInitiallyScrolled) { + flatListRef.current?.scrollToOffset({ + animated: false, + offset: height - contentHeight.value, + }) + } + + contentHeight.value = height + + // This number _must_ be the height of the MaybeLoader component + if (height <= 50 || !isAtBottom.value) { + return + } + + flatListRef.current?.scrollToOffset({ + animated: hasInitiallyScrolled, + offset: height, + }) + isMomentumScrolling.value = true + }, + [ + contentHeight, + hasInitiallyScrolled, + isAtBottom.value, + isAtTop.value, + isMomentumScrolling, + ], + ) + + // The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached` + // immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls. + const onStartReached = useCallback(() => { + if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) { + chat.fetchMessageHistory() + } + }, [chat, hasInitiallyScrolled]) + + const onSendMessage = useCallback( + (text: string) => { + if (chat.status === ConvoStatus.Ready) { + chat.sendMessage({ + text, + }) + } + }, + [chat], + ) + + const onScroll = React.useCallback( + (e: ReanimatedScrollEvent) => { + 'worklet' + const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height + + // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom + // when a new message is added, hence the 100 pixel offset + isAtBottom.value = e.contentSize.height - 100 < bottomOffset + isAtTop.value = e.contentOffset.y <= 1 + + // This number _must_ be the height of the MaybeLoader component. + // We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which + // adds a 50 pixel offset. + if (contentHeight.value > 50 && !hasInitiallyScrolled) { + runOnJS(setHasInitiallyScrolled)(true) + } + }, + [contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop], + ) + + const onMomentumEnd = React.useCallback(() => { + 'worklet' + isMomentumScrolling.value = false + }, [isMomentumScrolling]) + + const scrollToEnd = React.useCallback(() => { + requestAnimationFrame(() => { + if (isMomentumScrolling.value) return + + flatListRef.current?.scrollToEnd({animated: true}) + isMomentumScrolling.value = true + }) + }, [isMomentumScrolling]) + + const {bottom: bottomInset, top: topInset} = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60 + + // This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly. + const scrollToEndNow = React.useCallback(() => { + flatListRef.current?.scrollToEnd({animated: false}) + }, []) + + useKeyboardHandler({ + onMove: () => { + 'worklet' + runOnJS(scrollToEndNow)() + }, + }) + + return ( + + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + + + } + /> + + + + ) +} diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 239425a2f5..11044c2136 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,12 +1,26 @@ -import React from 'react' -import {View} from 'react-native' +import React, {useCallback} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {KeyboardProvider} from 'react-native-keyboard-controller' +import {AppBskyActorDefs} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' -import {ViewHeader} from '#/view/com/util/ViewHeader' +import {BACK_HITSLOP} from 'lib/constants' +import {isWeb} from 'platform/detection' +import {ChatProvider, useChat} from 'state/messages' +import {ConvoStatus} from 'state/messages/convo' +import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' +import {CenteredView} from 'view/com/util/Views' +import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ConvoMenu} from '#/components/dms/ConvoMenu' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' type Props = NativeStackScreenProps< @@ -14,19 +28,121 @@ type Props = NativeStackScreenProps< 'MessagesConversation' > export function MessagesConversationScreen({route}: Props) { - const chatId = route.params.conversation - const {_} = useLingui() - const gate = useGate() + const convoId = route.params.conversation + if (!gate('dms')) return return ( - - + + + + ) +} + +function Inner() { + const chat = useChat() + + if ( + chat.status === ConvoStatus.Uninitialized || + chat.status === ConvoStatus.Initializing + ) { + return + } + + if (chat.status === ConvoStatus.Error) { + // TODO error + return null + } + + /* + * Any other chat states (atm) are "ready" states + */ + + return ( + + +
+ + + + ) +} + +let Header = ({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewBasic +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const {gtTablet} = useBreakpoints() + const navigation = useNavigation() + const chat = useChat() + + const onPressBack = useCallback(() => { + if (isWeb) { + navigation.replace('Messages') + } else { + navigation.pop() + } + }, [navigation]) + + const onUpdateConvo = useCallback(() => { + // TODO eric update muted state + }, []) + + return ( + + {!gtTablet ? ( + + + + ) : ( + + )} + + + + {profile.displayName} + + + {chat.status === ConvoStatus.Ready ? ( + + ) : ( + + )} ) } +Header = React.memo(Header) diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index c4490aa5c5..ce8f52af9a 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -1,31 +1,59 @@ -import React, {useCallback, useState} from 'react' +/* eslint-disable react/prop-types */ + +import React, {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/macro' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useInfiniteQuery} from '@tanstack/react-query' +import {sha256} from 'js-sha256' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {MessagesTabNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {isNative} from '#/platform/detection' +import {useListConvos} from '#/state/queries/messages/list-converations' +import {useSession} from '#/state/session' import {List} from '#/view/com/util/List' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {ViewHeader} from '#/view/com/util/ViewHeader' -import {useTheme} from '#/alf' -import {atoms as a} from '#/alf' +import {CenteredView} from '#/view/com/util/Views' +import {ScrollView} from '#/view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {DialogControlProps, useDialogControl} from '#/components/Dialog' +import {ConvoMenu} from '#/components/dms/ConvoMenu' +import {NewChat} from '#/components/dms/NewChat' +import * as TextField from '#/components/forms/TextField' +import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' import {Link} from '#/components/Link' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {useMenuControl} from '#/components/Menu' import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' +import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage' -type Props = NativeStackScreenProps -export function MessagesListScreen({}: Props) { +type Props = NativeStackScreenProps +export function MessagesScreen({navigation}: Props) { const {_} = useLingui() const t = useTheme() + const newChatControl = useDialogControl() + const {gtMobile} = useBreakpoints() + + // TEMP + const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage() + const hasValidServiceUrl = useMemo(() => { + const hash = sha256(serviceUrl) + return ( + hash === + 'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f' + ) + }, [serviceUrl]) const renderButton = useCallback(() => { return ( @@ -49,18 +77,20 @@ export function MessagesListScreen({}: Props) { fetchNextPage, error, refetch, - } = usePlaceholderConversations() + } = useListConvos({refetchInterval: 15_000}) + + useRefreshOnFocus(refetch) const isError = !!error - const conversations = React.useMemo(() => { + const conversations = useMemo(() => { if (data?.pages) { - return data.pages.flat() + return data.pages.flatMap(page => page.convos) } return [] }, [data]) - const onRefresh = React.useCallback(async () => { + const onRefresh = useCallback(async () => { setIsPTRing(true) try { await refetch() @@ -70,7 +100,7 @@ export function MessagesListScreen({}: Props) { setIsPTRing(false) }, [refetch, setIsPTRing]) - const onEndReached = React.useCallback(async () => { + const onEndReached = useCallback(async () => { if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() @@ -79,85 +109,104 @@ export function MessagesListScreen({}: Props) { } }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + const onNewChat = useCallback( + (conversation: string) => + navigation.navigate('MessagesConversation', {conversation}), + [navigation], + ) + + const onNavigateToSettings = useCallback(() => { + navigation.navigate('MessagesSettings') + }, [navigation]) + + const renderItem = useCallback( + ({item}: {item: ChatBskyConvoDefs.ConvoView}) => { + return + }, + [], + ) + const gate = useGate() if (!gate('dms')) return + if (!hasValidServiceUrl) { + return ( + + + Service URL + + setServiceUrl(text)} + autoCapitalize="none" + keyboardType="url" + label="https://" + /> + + + + ) + } + if (conversations.length < 1) { return ( - + {gtMobile ? ( + + + + ) : ( + )} - errorMessage={cleanError(error)} - onRetry={isError ? refetch : undefined} - /> + {!isError && } + + ) } return ( - - + + {!gtMobile && ( + + )} + { - return ( - - - - - - - {item.profile.displayName || item.profile.handle} - {' '} - - @{item.profile.handle} - - - {item.unread && ( - - )} - - - {item.lastMessage} - - - - ) - }} - keyExtractor={item => item.profile.did} + renderItem={renderItem} + keyExtractor={item => item.id} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} + ListHeaderComponent={ + + } ListFooterComponent={ ) } -function usePlaceholderConversations() { - const {getAgent} = useAgent() - - return useInfiniteQuery({ - queryKey: ['messages'], - queryFn: async () => { - const people = await getAgent().getProfiles({actors: PLACEHOLDER_PEOPLE}) - return people.data.profiles.map(profile => ({ - profile, - unread: Math.random() > 0.5, - lastMessage: getRandomPost(), - })) - }, - initialPageParam: undefined, - getNextPageParam: () => undefined, - }) +function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + + let lastMessage = _(msg`No messages yet`) + let lastMessageSentAt: string | null = null + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + if (convo.lastMessage.sender?.did === currentAccount?.did) { + lastMessage = _(msg`You: ${convo.lastMessage.text}`) + } else { + lastMessage = convo.lastMessage.text + } + lastMessageSentAt = convo.lastMessage.sentAt + } + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { + lastMessage = _(msg`Message deleted`) + } + + const otherUser = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherUser) { + return null + } + + return ( + + {({hovered, pressed}) => ( + + + + + + + 0 && a.font_bold]}> + {otherUser.displayName || otherUser.handle} + {' '} + {lastMessageSentAt ? ( + + {({timeElapsed}) => ( + + @{otherUser.handle} · {timeElapsed} + + )} + + ) : ( + + @{otherUser.handle} + + )} + + 0 + ? a.font_bold + : t.atoms.text_contrast_medium, + ]}> + {lastMessage} + + + {convo.unreadCount > 0 && ( + + )} + + + )} + + ) } -const PLACEHOLDER_PEOPLE = [ - 'pfrazee.com', - 'haileyok.com', - 'danabra.mov', - 'esb.lol', - 'samuel.bsky.team', -] - -function getRandomPost() { - const num = Math.floor(Math.random() * 10) - switch (num) { - case 0: - return 'hello' - case 1: - return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' - case 2: - return 'banger post' - case 3: - return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' - case 4: - return 'lol look at this bug' - case 5: - return 'wow' - case 6: - return "that's pretty cool, wow!" - case 7: - return 'I think this is a bug' - case 8: - return 'Hello World!' - case 9: - return 'DMs when???' - default: - return 'this is unlikely' +function DesktopHeader({ + newChatControl, + onNavigateToSettings, +}: { + newChatControl: DialogControlProps + onNavigateToSettings: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile, gtTablet} = useBreakpoints() + + if (!gtMobile) { + return null } + + return ( + + + Messages + + + + {gtTablet && ( + + )} + + + ) } diff --git a/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx new file mode 100644 index 0000000000..d78128b5c7 --- /dev/null +++ b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import {useAsyncStorage} from '@react-native-async-storage/async-storage' + +/** + * TEMP: REMOVE BEFORE RELEASE + * + * Clip clop trivia: + * + * A little known fact about the term "clip clop" is that it may refer to a unit of time. It is unknown what the exact + * length of a clip clop is, but it is generally agreed that it is approximately 9 minutes and 30 seconds, or 570 + * seconds. + * + * The term "clip clop" may also be used in other contexts, although it is unknown what all of these contexts may be. + * Recently, the term has been used among many young adults to refer to a type of social media functionality, although + * the exact nature of this functionality is also unknown. It is believed that the term may have originated from a + * popular video game, but this has not been confirmed. + * + */ + +const DmServiceUrlStorageContext = React.createContext<{ + serviceUrl: string + setServiceUrl: (value: string) => void +}>({ + serviceUrl: '', + setServiceUrl: () => {}, +}) + +export const useDmServiceUrlStorage = () => + React.useContext(DmServiceUrlStorageContext) + +export function DmServiceUrlProvider({children}: {children: React.ReactNode}) { + const [serviceUrl, setServiceUrl] = React.useState('') + const {getItem, setItem: setItemInner} = useAsyncStorage('dmServiceUrl') + + React.useEffect(() => { + ;(async () => { + const v = await getItem() + setServiceUrl(v ?? '') + })() + }, [getItem]) + + const setItem = React.useCallback( + (v: string) => { + setItemInner(v) + setServiceUrl(v) + }, + [setItemInner], + ) + + const value = React.useMemo( + () => ({ + serviceUrl, + setServiceUrl: setItem, + }), + [serviceUrl, setItem], + ) + + return ( + + {children} + + ) +} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx index 7e4ea1f8b8..f0ba36e39f 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx @@ -2,13 +2,13 @@ import React from 'react' import {View, ViewStyle} from 'react-native' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {useTheme, atoms as a, flatten} from '#/alf' -import {Text} from '#/components/Typography' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, flatten, useTheme} from '#/alf' import {useItemContext} from '#/components/forms/Toggle' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {useModerationOpts} from '#/state/queries/preferences' import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' export function SuggestedAccountCard({ profile, diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index e9bc3f0fde..7b2ad2b999 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfilesQuery} from '#/state/queries/profile' import { DescriptionText, diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index 50918c4ce6..caa0aa28a4 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -26,7 +26,7 @@ export function CaptchaWebView({ stateParam: string state?: SignupState onSuccess: (code: string) => void - onError: () => void + onError: (error: unknown) => void }) { const redirectHost = React.useMemo(() => { if (!state?.serviceUrl) return 'bsky.app' @@ -56,7 +56,7 @@ export function CaptchaWebView({ const code = urlp.searchParams.get('code') if (urlp.searchParams.get('state') !== stateParam || !code) { - onError() + onError({error: 'Invalid state or code'}) return } @@ -74,6 +74,12 @@ export function CaptchaWebView({ onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} onNavigationStateChange={onNavigationStateChange} scrollEnabled={false} + onError={e => { + onError(e.nativeEvent) + }} + onHttpError={e => { + onError(e.nativeEvent) + }} /> ) } diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx index 7791a58dd7..8faaf90a03 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -13,8 +13,20 @@ export function CaptchaWebView({ url: string stateParam: string onSuccess: (code: string) => void - onError: () => void + onError: (error: unknown) => void }) { + React.useEffect(() => { + const timeout = setTimeout(() => { + onError({ + errorMessage: 'User did not complete the captcha within 30 seconds', + }) + }, 30e3) + + return () => { + clearTimeout(timeout) + } + }, [onError]) + const onLoad = React.useCallback(() => { // @ts-ignore web const frame: HTMLIFrameElement = document.getElementById( @@ -32,12 +44,14 @@ export function CaptchaWebView({ const code = urlp.searchParams.get('code') if (urlp.searchParams.get('state') !== stateParam || !code) { - onError() + onError({error: 'Invalid state or code'}) return } onSuccess(code) - } catch (e) { - // We don't need to handle this + } catch (e: unknown) { + // We don't actually want to record an error here, because this will happen quite a bit. We will only be able to + // get hte href of the iframe if it's on our domain, so all the hcaptcha requests will throw here, although it's + // harmless. Our other indicators of time-to-complete and back press should be more reliable in catching issues. } }, [stateParam, onSuccess, onError]) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index 2429b0c5e9..d0fc4e9341 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {nanoid} from 'nanoid/non-secure' import {createFullHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' @@ -43,12 +44,19 @@ export function StepCaptcha() { [submit], ) - const onError = React.useCallback(() => { - dispatch({ - type: 'setError', - value: _(msg`Error receiving captcha response.`), - }) - }, [_, dispatch]) + const onError = React.useCallback( + (error?: unknown) => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + logger.error('Signup Flow Error', { + registrationHandle: state.handle, + error, + }) + }, + [_, dispatch, state.handle], + ) return ( diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 6758f7fa14..5e2596d8cf 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -8,6 +8,7 @@ import {useAnalytics} from '#/lib/analytics/analytics' import {FEEDBACK_FORM_URL} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' import {createFullHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' import {useAgent} from '#/state/session' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' @@ -119,11 +120,19 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { const onBackPress = React.useCallback(() => { if (state.activeStep !== SignupStep.INFO) { + if (state.activeStep === SignupStep.CAPTCHA) { + logger.error('Signup Flow Error', { + errorMessage: + 'User went back from captcha step. Possibly encountered an error.', + registrationHandle: state.handle, + }) + } + dispatch({type: 'prev'}) } else { onPressBack() } - }, [onPressBack, state.activeStep]) + }, [onPressBack, state.activeStep, state.handle]) return ( diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 86a144368f..d6cf9c44c4 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -246,6 +246,10 @@ export function useSubmitSignup({ !verificationCode ) { dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) + logger.error('Signup Flow Error', { + errorMessage: 'Verification captcha code was not set.', + registrationHandle: state.handle, + }) return dispatch({ type: 'setError', value: _(msg`Please complete the verification captcha.`), @@ -282,20 +286,17 @@ export function useSubmitSignup({ return } - if ([400, 429].includes(e.status)) { - logger.warn('Failed to create account', {message: e}) - } else { - logger.error(`Failed to create account (${e.status} status)`, { - message: e, - }) - } - const error = cleanError(errMsg) const isHandleError = error.toLowerCase().includes('handle') dispatch({type: 'setIsLoading', value: false}) - dispatch({type: 'setError', value: cleanError(errMsg)}) + dispatch({type: 'setError', value: error}) dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) + + logger.error('Signup Flow Error', { + errorMessage: error, + registrationHandle: state.handle, + }) } finally { dispatch({type: 'setIsLoading', value: false}) } diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts new file mode 100644 index 0000000000..44fe16fefa --- /dev/null +++ b/src/state/messages/__tests__/convo.test.ts @@ -0,0 +1,65 @@ +import {describe, it} from '@jest/globals' + +describe(`#/state/messages/convo`, () => { + describe(`init`, () => { + it.todo(`fails if sender and recipients aren't found`) + it.todo(`cannot re-initialize from a non-unintialized state`) + it.todo(`can re-initialize from a failed state`) + }) + + describe(`resume`, () => { + it.todo(`restores previous state if resume fails`) + }) + + describe(`suspend`, () => { + it.todo(`cannot be interacted with when suspended`) + it.todo(`polling is stopped when suspended`) + }) + + describe(`read states`, () => { + it.todo(`should mark messages as read as they come in`) + }) + + describe(`history fetching`, () => { + it.todo(`fetches initial chat history`) + it.todo(`fetches additional chat history`) + it.todo(`handles history fetch failure`) + it.todo(`does not insert deleted messages`) + }) + + describe(`sending messages`, () => { + it.todo(`optimistically adds sending messages`) + it.todo(`sends messages in order`) + it.todo(`failed message send fails all sending messages`) + it.todo(`can retry all failed messages via retry ConvoItem`) + it.todo( + `successfully sent messages are re-ordered, if needed, by events received from server`, + ) + }) + + describe(`deleting messages`, () => { + it.todo(`messages are optimistically deleted from the chat`) + it.todo(`messages are confirmed deleted via events from the server`) + }) + + describe(`log handling`, () => { + it.todo(`updates rev to latest message received`) + it.todo(`only handles log events for this convoId`) + it.todo(`does not insert deleted messages`) + }) + + describe(`item ordering`, () => { + it.todo(`pending items are first, and in order`) + it.todo(`new message items are next, and in order`) + it.todo(`past message items are next, and in order`) + }) + + describe(`inactivity`, () => { + it.todo( + `below a certain threshold of inactivity, restore entirely from log`, + ) + it.todo( + `above a certain threshold of inactivity, rehydrate entirely fresh state`, + ) + }) +}) diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts new file mode 100644 index 0000000000..81ab94f43b --- /dev/null +++ b/src/state/messages/convo.ts @@ -0,0 +1,900 @@ +import {AppBskyActorDefs} from '@atproto/api' +import { + BskyAgent, + ChatBskyConvoDefs, + ChatBskyConvoSendMessage, +} from '@atproto-labs/api' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' + +export type ConvoParams = { + convoId: string + agent: BskyAgent + __tempFromUserDid: string +} + +export enum ConvoStatus { + Uninitialized = 'uninitialized', + Initializing = 'initializing', + Resuming = 'resuming', + Ready = 'ready', + Error = 'error', + Backgrounded = 'backgrounded', + Suspended = 'suspended', +} + +export enum ConvoItemError { + HistoryFailed = 'historyFailed', + ResumeFailed = 'resumeFailed', + PollFailed = 'pollFailed', +} + +export enum ConvoError { + InitFailed = 'initFailed', +} + +export type ConvoItem = + | { + type: 'message' | 'pending-message' + key: string + message: ChatBskyConvoDefs.MessageView + nextMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + } + | { + type: 'deleted-message' + key: string + message: ChatBskyConvoDefs.DeletedMessageView + nextMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + } + | { + type: 'pending-retry' + key: string + retry: () => void + } + | { + type: 'error-recoverable' + key: string + code: ConvoItemError + retry: () => void + } + +export type ConvoState = + | { + status: ConvoStatus.Uninitialized + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + | { + status: ConvoStatus.Initializing + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: boolean + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + | { + status: ConvoStatus.Ready + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => void + fetchMessageHistory: () => void + } + | { + status: ConvoStatus.Suspended + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise + fetchMessageHistory: () => Promise + } + | { + status: ConvoStatus.Backgrounded + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise + fetchMessageHistory: () => Promise + } + | { + status: ConvoStatus.Resuming + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise + fetchMessageHistory: () => Promise + } + | { + status: ConvoStatus.Error + items: [] + convo: undefined + error: any + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined + } + +const ACTIVE_POLL_INTERVAL = 2e3 +const BACKGROUND_POLL_INTERVAL = 10e3 + +export function isConvoItemMessage( + item: ConvoItem, +): item is ConvoItem & {type: 'message'} { + if (!item) return false + return ( + item.type === 'message' || + item.type === 'deleted-message' || + item.type === 'pending-message' + ) +} + +export class Convo { + private agent: BskyAgent + private __tempFromUserDid: string + + private pollInterval = ACTIVE_POLL_INTERVAL + private status: ConvoStatus = ConvoStatus.Uninitialized + private error: + | { + code: ConvoError + exception?: Error + retry: () => void + } + | undefined + private historyCursor: string | undefined | null = undefined + private isFetchingHistory = false + private eventsCursor: string | undefined = undefined + private pollingFailure = false + + private pastMessages: Map< + string, + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + > = new Map() + private newMessages: Map< + string, + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + > = new Map() + private pendingMessages: Map< + string, + {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} + > = new Map() + private deletedMessages: Set = new Set() + private footerItems: Map = new Map() + private headerItems: Map = new Map() + + private pendingEventIngestion: Promise | undefined + private isProcessingPendingMessages = false + + convoId: string + convo: ChatBskyConvoDefs.ConvoView | undefined + sender: AppBskyActorDefs.ProfileViewBasic | undefined + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined + snapshot: ConvoState | undefined + + constructor(params: ConvoParams) { + this.convoId = params.convoId + this.agent = params.agent + this.__tempFromUserDid = params.__tempFromUserDid + + this.subscribe = this.subscribe.bind(this) + this.getSnapshot = this.getSnapshot.bind(this) + this.sendMessage = this.sendMessage.bind(this) + this.deleteMessage = this.deleteMessage.bind(this) + this.fetchMessageHistory = this.fetchMessageHistory.bind(this) + } + + private commit() { + this.snapshot = undefined + this.subscribers.forEach(subscriber => subscriber()) + } + + private subscribers: (() => void)[] = [] + + subscribe(subscriber: () => void) { + if (this.subscribers.length === 0) this.init() + + this.subscribers.push(subscriber) + + return () => { + this.subscribers = this.subscribers.filter(s => s !== subscriber) + if (this.subscribers.length === 0) this.suspend() + } + } + + getSnapshot(): ConvoState { + if (!this.snapshot) this.snapshot = this.generateSnapshot() + // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) + return this.snapshot + } + + private generateSnapshot(): ConvoState { + switch (this.status) { + case ConvoStatus.Initializing: { + return { + status: ConvoStatus.Initializing, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + case ConvoStatus.Suspended: + case ConvoStatus.Backgrounded: + case ConvoStatus.Resuming: + case ConvoStatus.Ready: { + return { + status: this.status, + items: this.getItems(), + convo: this.convo!, + error: undefined, + sender: this.sender!, + recipients: this.recipients!, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: this.deleteMessage, + sendMessage: this.sendMessage, + fetchMessageHistory: this.fetchMessageHistory, + } + } + case ConvoStatus.Error: { + return { + status: ConvoStatus.Error, + items: [], + convo: undefined, + error: this.error, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + default: { + return { + status: ConvoStatus.Uninitialized, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + } + } + + async init() { + logger.debug('Convo: init', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Uninitialized || + this.status === ConvoStatus.Error + ) { + try { + this.status = ConvoStatus.Initializing + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + await this.fetchMessageHistory() + + this.pollEvents() + } catch (e: any) { + logger.error('Convo: failed to init') + this.error = { + exception: e, + code: ConvoError.InitFailed, + retry: () => { + this.error = undefined + this.init() + }, + } + this.status = ConvoStatus.Error + this.commit() + } + } else { + logger.warn(`Convo: cannot init from ${this.status}`) + } + } + + async resume() { + logger.debug('Convo: resume', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Suspended || + this.status === ConvoStatus.Backgrounded + ) { + const fromStatus = this.status + + try { + this.status = ConvoStatus.Resuming + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + // throw new Error('UNCOMMENT TO TEST RESUME FAILURE') + + this.pollInterval = ACTIVE_POLL_INTERVAL + this.pollEvents() + } catch (e) { + logger.error('Convo: failed to resume') + + this.footerItems.set(ConvoItemError.ResumeFailed, { + type: 'error-recoverable', + key: ConvoItemError.ResumeFailed, + code: ConvoItemError.ResumeFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.ResumeFailed) + this.resume() + }, + }) + + this.status = fromStatus + this.commit() + } + } else { + logger.warn(`Convo: cannot resume from ${this.status}`) + } + } + + async background() { + logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Backgrounded + this.pollInterval = BACKGROUND_POLL_INTERVAL + this.commit() + } + + async suspend() { + logger.debug('Convo: suspended', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Suspended + this.commit() + } + + async refreshConvo() { + const response = await this.agent.api.chat.bsky.convo.getConvo( + { + convoId: this.convoId, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + this.convo = response.data.convo + this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid) + this.recipients = this.convo.members.filter( + m => m.did !== this.__tempFromUserDid, + ) + + /* + * Prevent invalid states + */ + if (!this.sender) { + throw new Error('Convo: could not find sender in convo') + } + if (!this.recipients) { + throw new Error('Convo: could not find recipients in convo') + } + } + + async fetchMessageHistory() { + logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo) + + /* + * If historyCursor is null, we've fetched all history. + */ + if (this.historyCursor === null) return + + /* + * Don't fetch again if a fetch is already in progress + */ + if (this.isFetchingHistory) return + + /* + * If we've rendered a retry state for history fetching, exit. Upon retry, + * this will be removed and we'll try again. + */ + if (this.headerItems.has(ConvoItemError.HistoryFailed)) return + + try { + this.isFetchingHistory = true + this.commit() + + /* + * Delay if paginating while scrolled to prevent momentum scrolling from + * jerking the list around, plus makes it feel a little more human. + */ + if (this.pastMessages.size > 0) { + await new Promise(y => setTimeout(y, 500)) + // throw new Error('UNCOMMENT TO TEST RETRY') + } + + const response = await this.agent.api.chat.bsky.convo.getMessages( + { + cursor: this.historyCursor, + convoId: this.convoId, + limit: isNative ? 25 : 50, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {cursor, messages} = response.data + + this.historyCursor = cursor ?? null + + for (const message of messages) { + if ( + ChatBskyConvoDefs.isMessageView(message) || + ChatBskyConvoDefs.isDeletedMessageView(message) + ) { + this.pastMessages.set(message.id, message) + + // set to latest rev + if ( + message.rev > (this.eventsCursor = this.eventsCursor || message.rev) + ) { + this.eventsCursor = message.rev + } + } + } + } catch (e: any) { + logger.error('Convo: failed to fetch message history') + + this.headerItems.set(ConvoItemError.HistoryFailed, { + type: 'error-recoverable', + key: ConvoItemError.HistoryFailed, + code: ConvoItemError.HistoryFailed, + retry: () => { + this.headerItems.delete(ConvoItemError.HistoryFailed) + this.fetchMessageHistory() + }, + }) + } finally { + this.isFetchingHistory = false + this.commit() + } + } + + private async pollEvents() { + if ( + this.status === ConvoStatus.Ready || + this.status === ConvoStatus.Backgrounded + ) { + if (this.pendingEventIngestion) return + + /* + * Represents a failed state, which is retryable. + */ + if (this.pollingFailure) return + + setTimeout(async () => { + this.pendingEventIngestion = this.ingestLatestEvents() + await this.pendingEventIngestion + this.pendingEventIngestion = undefined + this.pollEvents() + }, this.pollInterval) + } + } + + async ingestLatestEvents() { + try { + // throw new Error('UNCOMMENT TO TEST POLL FAILURE') + const response = await this.agent.api.chat.bsky.convo.getLog( + { + cursor: this.eventsCursor, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {logs} = response.data + + let needsCommit = false + + for (const log of logs) { + /* + * If there's a rev, we should handle it. If there's not a rev, we don't + * know what it is. + */ + if (typeof log.rev === 'string') { + /* + * We only care about new events + */ + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { + /* + * Update rev regardless of if it's a log type we care about or not + */ + this.eventsCursor = log.rev + + /* + * This is VERY important. We don't want to insert any messages from + * your other chats. + */ + if (log.convoId !== this.convoId) continue + + if ( + ChatBskyConvoDefs.isLogCreateMessage(log) && + ChatBskyConvoDefs.isMessageView(log.message) + ) { + if (this.newMessages.has(log.message.id)) { + // Trust the log as the source of truth on ordering + this.newMessages.delete(log.message.id) + } + this.newMessages.set(log.message.id, log.message) + needsCommit = true + } else if ( + ChatBskyConvoDefs.isLogDeleteMessage(log) && + ChatBskyConvoDefs.isDeletedMessageView(log.message) + ) { + /* + * Update if we have this in state. If we don't, don't worry about it. + */ + if (this.pastMessages.has(log.message.id)) { + /* + * For now, we remove deleted messages from the thread, if we receive one. + * + * To support them, it'd look something like this: + * this.pastMessages.set(log.message.id, log.message) + */ + this.pastMessages.delete(log.message.id) + this.newMessages.delete(log.message.id) + this.deletedMessages.delete(log.message.id) + needsCommit = true + } + } + } + } + } + + if (needsCommit) { + this.commit() + } + } catch (e: any) { + logger.error('Convo: failed to poll events') + this.pollingFailure = true + this.footerItems.set(ConvoItemError.PollFailed, { + type: 'error-recoverable', + key: ConvoItemError.PollFailed, + code: ConvoItemError.PollFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.PollFailed) + this.pollingFailure = false + this.commit() + this.pollEvents() + }, + }) + this.commit() + } + } + + async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { + // Ignore empty messages for now since they have no other purpose atm + if (!message.text.trim()) return + + logger.debug('Convo: send message', {}, logger.DebugContext.convo) + + const tempId = nanoid() + + this.pendingMessages.set(tempId, { + id: tempId, + message, + }) + this.commit() + + if (!this.isProcessingPendingMessages) { + this.processPendingMessages() + } + } + + async processPendingMessages() { + logger.debug( + `Convo: processing messages (${this.pendingMessages.size} remaining)`, + {}, + logger.DebugContext.convo, + ) + + const pendingMessage = Array.from(this.pendingMessages.values()).shift() + + /* + * If there are no pending messages, we're done. + */ + if (!pendingMessage) { + this.isProcessingPendingMessages = false + return + } + + try { + this.isProcessingPendingMessages = true + + // throw new Error('UNCOMMENT TO TEST RETRY') + const {id, message} = pendingMessage + + const response = await this.agent.api.chat.bsky.convo.sendMessage( + { + convoId: this.convoId, + message, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const res = response.data + + /* + * Insert into `newMessages` as soon as we have a real ID. That way, when + * we get an event log back, we can replace in situ. + */ + this.newMessages.set(res.id, { + ...res, + $type: 'chat.bsky.convo.defs#messageView', + sender: this.sender, + }) + this.pendingMessages.delete(id) + + await this.processPendingMessages() + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + + async batchRetryPendingMessages() { + logger.debug( + `Convo: retrying ${this.pendingMessages.size} pending messages`, + {}, + logger.DebugContext.convo, + ) + + this.footerItems.delete('pending-retry') + this.commit() + + try { + const messageArray = Array.from(this.pendingMessages.values()) + const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( + { + items: messageArray.map(({message}) => ({ + convoId: this.convoId, + message, + })), + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {items} = data + + /* + * Insert into `newMessages` as soon as we have a real ID. That way, when + * we get an event log back, we can replace in situ. + */ + for (const item of items) { + this.newMessages.set(item.id, { + ...item, + $type: 'chat.bsky.convo.defs#messageView', + sender: this.convo?.members.find( + m => m.did === this.__tempFromUserDid, + ), + }) + } + + for (const pendingMessage of messageArray) { + this.pendingMessages.delete(pendingMessage.id) + } + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + + async deleteMessage(messageId: string) { + logger.debug('Convo: delete message', {}, logger.DebugContext.convo) + + this.deletedMessages.add(messageId) + this.commit() + + try { + await this.agent.api.chat.bsky.convo.deleteMessageForSelf( + { + convoId: this.convoId, + messageId, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + } catch (e) { + this.deletedMessages.delete(messageId) + this.commit() + throw e + } + } + + /* + * Items in reverse order, since FlatList inverts + */ + getItems(): ConvoItem[] { + const items: ConvoItem[] = [] + + this.headerItems.forEach(item => { + items.push(item) + }) + + this.pastMessages.forEach(m => { + if (ChatBskyConvoDefs.isMessageView(m)) { + items.unshift({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { + items.unshift({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + this.newMessages.forEach(m => { + if (ChatBskyConvoDefs.isMessageView(m)) { + items.push({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { + items.push({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + this.pendingMessages.forEach(m => { + items.push({ + type: 'pending-message', + key: m.id, + message: { + ...m.message, + id: nanoid(), + rev: '__fake__', + sentAt: new Date().toISOString(), + sender: this.sender, + }, + nextMessage: null, + }) + }) + + this.footerItems.forEach(item => { + items.push(item) + }) + + return items + .filter(item => { + if (isConvoItemMessage(item)) { + return !this.deletedMessages.has(item.message.id) + } + return true + }) + .map((item, i, arr) => { + let nextMessage = null + const isMessage = isConvoItemMessage(item) + + if (isMessage) { + if ( + isMessage && + (ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message)) + ) { + const next = arr[i + 1] + + if ( + isConvoItemMessage(next) && + next && + (ChatBskyConvoDefs.isMessageView(next.message) || + ChatBskyConvoDefs.isDeletedMessageView(next.message)) + ) { + nextMessage = next.message + } + } + + return { + ...item, + nextMessage, + } + } + + return item + }) + } +} diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx new file mode 100644 index 0000000000..22c4242e2d --- /dev/null +++ b/src/state/messages/index.tsx @@ -0,0 +1,48 @@ +import React, {useContext, useState, useSyncExternalStore} from 'react' +import {BskyAgent} from '@atproto-labs/api' +import {useFocusEffect} from '@react-navigation/native' + +import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' +import {useAgent} from '#/state/session' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' + +const ChatContext = React.createContext(null) + +export function useChat() { + const ctx = useContext(ChatContext) + if (!ctx) { + throw new Error('useChat must be used within a ChatProvider') + } + return ctx +} + +export function ChatProvider({ + children, + convoId, +}: Pick & {children: React.ReactNode}) { + const {serviceUrl} = useDmServiceUrlStorage() + const {getAgent} = useAgent() + const [convo] = useState( + () => + new Convo({ + convoId, + agent: new BskyAgent({ + service: serviceUrl, + }), + __tempFromUserDid: getAgent().session?.did!, + }), + ) + const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) + + useFocusEffect( + React.useCallback(() => { + convo.resume() + + return () => { + convo.background() + } + }, [convo]), + ) + + return {children} +} diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index f57172d2fe..5fe0f9bd0a 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,11 +1,11 @@ import EventEmitter from 'eventemitter3' + +import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {defaults, Schema} from '#/state/persisted/schema' import {migrate} from '#/state/persisted/legacy' +import {defaults, Schema} from '#/state/persisted/schema' import * as store from '#/state/persisted/store' -import BroadcastChannel from '#/lib/broadcast' - -export type {Schema, PersistedAccount} from '#/state/persisted/schema' +export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f090365a31..77a79b78e4 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -4,7 +4,10 @@ import {deviceLocales, prefersReducedMotion} from '#/platform/detection' const externalEmbedOptions = ['show', 'hide'] as const -// only data needed for rendering account page +/** + * A account persisted to storage. Stored in the `accounts[]` array. Contains + * base account info and access tokens. + */ const accountSchema = z.object({ service: z.string(), did: z.string(), @@ -19,12 +22,26 @@ const accountSchema = z.object({ }) export type PersistedAccount = z.infer +/** + * The current account. Stored in the `currentAccount` field. + * + * In previous versions, this included tokens and other info. Now, it's used + * only to reference the `did` field, and all other fields are marked as + * optional. They should be considered deprecated and not used, but are kept + * here for backwards compat. + */ +const currentAccountSchema = accountSchema.extend({ + service: z.string().optional(), + handle: z.string().optional(), +}) +export type PersistedCurrentAccount = z.infer + export const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), darkTheme: z.enum(['dim', 'dark']).optional(), session: z.object({ accounts: z.array(accountSchema), - currentAccount: accountSchema.optional(), + currentAccount: currentAccountSchema.optional(), }), reminders: z.object({ lastEmailConfirm: z.string().optional(), @@ -63,6 +80,7 @@ export const schema = z.object({ pdsAddressHistory: z.array(z.string()).optional(), disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), + kawaii: z.boolean().optional(), }) export type Schema = z.infer @@ -100,4 +118,5 @@ export const defaults: Schema = { pdsAddressHistory: [], disableHaptics: false, disableAutoplay: prefersReducedMotion, + kawaii: false, } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 5c8fab2ad7..5bca354525 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,11 +1,13 @@ import React from 'react' +import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' import {Provider as DisableHapticsProvider} from './disable-haptics' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as HiddenPostsProvider} from './hidden-posts' import {Provider as InAppBrowserProvider} from './in-app-browser' +import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' export { @@ -30,7 +32,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + + {children} + + diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx new file mode 100644 index 0000000000..4aa95ef8b0 --- /dev/null +++ b/src/state/preferences/kawaii.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +import {isWeb} from '#/platform/detection' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['kawaii'] + +const stateContext = React.createContext( + persisted.defaults.kawaii, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('kawaii')) + + const setStateWrapped = React.useCallback( + (kawaii: persisted.Schema['kawaii']) => { + setState(kawaii) + persisted.write('kawaii', kawaii) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('kawaii')) + }) + }, [setStateWrapped]) + + React.useEffect(() => { + // dumb and stupid but it's web only so just refresh the page if you want to change it + + if (isWeb) { + const kawaii = new URLSearchParams(window.location.search).get('kawaii') + switch (kawaii) { + case 'true': + setStateWrapped(true) + break + case 'false': + setStateWrapped(false) + break + } + } + }, [setStateWrapped]) + + return {children} +} + +export function useKawaiiMode() { + return React.useContext(stateContext) +} diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx index d60f8ccb88..e24a1144a2 100644 --- a/src/state/preferences/label-defs.tsx +++ b/src/state/preferences/label-defs.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api' +import {AppBskyLabelerDefs, InterpretedLabelValueDefinition} from '@atproto/api' + import {useLabelDefinitionsQuery} from '../queries/preferences' interface StateContext { @@ -13,10 +14,7 @@ const stateContext = React.createContext({ }) export function Provider({children}: React.PropsWithChildren<{}>) { - const {labelDefs, labelers} = useLabelDefinitionsQuery() - - const state = {labelDefs, labelers} - + const state = useLabelDefinitionsQuery() return {children} } diff --git a/src/state/preferences/moderation-opts.tsx b/src/state/preferences/moderation-opts.tsx new file mode 100644 index 0000000000..b0278d5e88 --- /dev/null +++ b/src/state/preferences/moderation-opts.tsx @@ -0,0 +1,61 @@ +import React, {createContext, useContext, useMemo} from 'react' +import {BSKY_LABELER_DID, ModerationOpts} from '@atproto/api' + +import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' +import {useSession} from '#/state/session' +import {usePreferencesQuery} from '../queries/preferences' + +export const moderationOptsContext = createContext( + undefined, +) + +// used in the moderation state devtool +export const moderationOptsOverrideContext = createContext< + ModerationOpts | undefined +>(undefined) + +export function useModerationOpts() { + return useContext(moderationOptsContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const override = useContext(moderationOptsOverrideContext) + const {currentAccount} = useSession() + const prefs = usePreferencesQuery() + const {labelDefs} = useLabelDefinitions() + const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs + + const userDid = currentAccount?.did + const moderationPrefs = prefs.data?.moderationPrefs + const value = useMemo(() => { + if (override) { + return override + } + if (!moderationPrefs) { + return undefined + } + return { + userDid, + prefs: { + ...moderationPrefs, + labelers: moderationPrefs.labelers.length + ? moderationPrefs.labelers + : [ + { + did: BSKY_LABELER_DID, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + }, + ], + hiddenPosts: hiddenPosts || [], + }, + labelDefs, + } + }, [override, userDid, labelDefs, moderationPrefs, hiddenPosts]) + + return ( + + {children} + + ) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 98b5aa17e6..8708a244bd 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -6,7 +6,8 @@ import {isJustAMute} from '#/lib/moderation' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' -import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences' +import {useModerationOpts} from '../preferences/moderation-opts' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences' const DEFAULT_MOD_OPTS = { userDid: undefined, @@ -23,7 +24,11 @@ export function useActorAutocompleteQuery( const moderationOpts = useModerationOpts() const {getAgent} = useAgent() - prefix = prefix.toLowerCase() + prefix = prefix.toLowerCase().trim() + if (prefix.endsWith('.')) { + // Going from "foo" to "foo." should not clear matches. + prefix = prefix.slice(0, -1) + } return useQuery({ staleTime: STALE.MINUTES.ONE, diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index e30528ca13..0635bf316b 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -1,11 +1,3 @@ -import {BskyAgent} from '@atproto/api' - -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' - -export const PUBLIC_BSKY_AGENT = new BskyAgent({ - service: PUBLIC_BSKY_SERVICE, -}) - export const STALE = { SECONDS: { FIFTEEN: 1e3 * 15, diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts new file mode 100644 index 0000000000..9456861d27 --- /dev/null +++ b/src/state/queries/messages/conversation.ts @@ -0,0 +1,25 @@ +import {BskyAgent} from '@atproto-labs/api' +import {useQuery} from '@tanstack/react-query' + +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useHeaders} from './temp-headers' + +const RQKEY_ROOT = 'convo' +export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] + +export function useConvoQuery(convoId: string) { + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useQuery({ + queryKey: RQKEY(convoId), + queryFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.getConvo( + {convoId}, + {headers}, + ) + return data.convo + }, + }) +} diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts new file mode 100644 index 0000000000..0a657c07e9 --- /dev/null +++ b/src/state/queries/messages/get-convo-for-members.ts @@ -0,0 +1,39 @@ +import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_KEY} from './conversation' +import {useHeaders} from './temp-headers' + +export function useGetConvoForMembers({ + onSuccess, + onError, +}: { + onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void + onError?: (error: Error) => void +}) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async (members: string[]) => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( + {members: members}, + {headers}, + ) + + return data + }, + onSuccess: data => { + queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts new file mode 100644 index 0000000000..0dd67fa0b1 --- /dev/null +++ b/src/state/queries/messages/leave-conversation.ts @@ -0,0 +1,68 @@ +import { + BskyAgent, + ChatBskyConvoLeaveConvo, + ChatBskyConvoListConvos, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useLeaveConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.leaveConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onMutate: () => { + queryClient.setQueryData( + CONVO_LIST_KEY, + (old?: { + pageParams: Array + pages: Array + }) => { + console.log('old', old) + if (!old) return old + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.filter(convo => convo.id !== convoId), + } + }), + } + }, + ) + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts new file mode 100644 index 0000000000..1e4ecb6d72 --- /dev/null +++ b/src/state/queries/messages/list-converations.ts @@ -0,0 +1,29 @@ +import {BskyAgent} from '@atproto-labs/api' +import {useInfiniteQuery} from '@tanstack/react-query' + +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useHeaders} from './temp-headers' + +export const RQKEY = ['convo-list'] +type RQPageParam = string | undefined + +export function useListConvos({refetchInterval}: {refetchInterval: number}) { + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useInfiniteQuery({ + queryKey: RQKEY, + queryFn: async ({pageParam}) => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.listConvos( + {cursor: pageParam}, + {headers}, + ) + + return data + }, + initialPageParam: undefined as RQPageParam, + getNextPageParam: lastPage => lastPage.cursor, + refetchInterval, + }) +} diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts new file mode 100644 index 0000000000..4840c65ade --- /dev/null +++ b/src/state/queries/messages/mute-conversation.ts @@ -0,0 +1,84 @@ +import { + BskyAgent, + ChatBskyConvoMuteConvo, + ChatBskyConvoUnmuteConvo, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_KEY} from './conversation' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useMuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.muteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} + +export function useUnmuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/temp-headers.ts b/src/state/queries/messages/temp-headers.ts new file mode 100644 index 0000000000..9e46e8a616 --- /dev/null +++ b/src/state/queries/messages/temp-headers.ts @@ -0,0 +1,11 @@ +import {useSession} from '#/state/session' + +// toy auth +export const useHeaders = () => { + const {currentAccount} = useSession() + return { + get Authorization() { + return currentAccount!.did + }, + } +} diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 1f21999017..80e5a4c472 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -28,8 +28,8 @@ import { import {useMutedThreads} from '#/state/muted-threads' import {useAgent} from '#/state/session' +import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' -import {useModerationOpts} from '../preferences' import {embedViewRecordToPostView, getEmbeddedPost} from '../util' import {FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 1c569e2a09..80333b524f 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -13,7 +13,7 @@ import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useMutedThreads} from '#/state/muted-threads' import {useAgent, useSession} from '#/state/session' -import {useModerationOpts} from '../preferences' +import {useModerationOpts} from '../../preferences/moderation-opts' import {truncateAndInvalidate} from '../util' import {RQKEY as RQKEY_NOTIFS} from './feed' import {CachedFeedPage, FeedPage} from './types' diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 06451a8394..dc86a9ba0d 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -32,7 +32,8 @@ import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' -import {useModerationOpts, usePreferencesQuery} from './preferences' +import {useModerationOpts} from '../preferences/moderation-opts' +import {usePreferencesQuery} from './preferences' import {embedViewRecordToPostView, getEmbeddedPost} from './util' type ActorDid = string diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 06e47391f2..f51eaac2a4 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,28 +1,24 @@ -import {createContext, useContext, useMemo} from 'react' import { AppBskyActorDefs, - BSKY_LABELER_DID, BskyFeedViewPreference, LabelPreference, - ModerationOpts, } from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {track} from '#/lib/analytics/analytics' +import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' -import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' import {STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, DEFAULT_LOGGED_OUT_PREFERENCES, DEFAULT_THREAD_VIEW_PREFS, } from '#/state/queries/preferences/const' -import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import { ThreadViewPreferences, UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' -import {useAgent, useSession} from '#/state/session' +import {useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' export * from '#/state/queries/preferences/const' @@ -36,7 +32,7 @@ export function usePreferencesQuery() { const {getAgent} = useAgent() return useQuery({ staleTime: STALE.SECONDS.FIFTEEN, - structuralSharing: true, + structuralSharing: replaceEqualDeep, refetchOnWindowFocus: true, queryKey: preferencesQueryKey, queryFn: async () => { @@ -79,44 +75,6 @@ export function usePreferencesQuery() { }) } -// used in the moderation state devtool -export const moderationOptsOverrideContext = createContext< - ModerationOpts | undefined ->(undefined) - -export function useModerationOpts() { - const override = useContext(moderationOptsOverrideContext) - const {currentAccount} = useSession() - const prefs = usePreferencesQuery() - const {labelDefs} = useLabelDefinitions() - const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs - const opts = useMemo(() => { - if (override) { - return override - } - if (!prefs.data) { - return - } - return { - userDid: currentAccount?.did, - prefs: { - ...prefs.data.moderationPrefs, - labelers: prefs.data.moderationPrefs.labelers.length - ? prefs.data.moderationPrefs.labelers - : [ - { - did: BSKY_LABELER_DID, - labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, - }, - ], - hiddenPosts: hiddenPosts || [], - }, - labelDefs, - } - }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts]) - return opts -} - export function useClearPreferencesMutation() { const queryClient = useQueryClient() const {getAgent} = useAgent() diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 3338130f4a..7740b1977c 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -18,11 +18,9 @@ import { } from '#/lib/api/feed/utils' import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' -import { - useModerationOpts, - usePreferencesQuery, -} from '#/state/queries/preferences' +import {usePreferencesQuery} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' +import {useModerationOpts} from '../preferences/moderation-opts' const suggestedFollowsQueryKeyRoot = 'suggested-follows' const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e45aa031f4..276e3b97b7 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,225 +1,199 @@ import React from 'react' -import { - AtpPersistSessionHandler, - BSKY_LABELER_DID, - BskyAgent, -} from '@atproto/api' -import {jwtDecode} from 'jwt-decode' +import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' -import {IS_TEST_USER} from '#/lib/constants' -import {logEvent, LogEvents, tryFetchGates} from '#/lib/statsig/statsig' -import {hasProp} from '#/lib/type-guards' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {logEvent, tryFetchGates} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' -import {PUBLIC_BSKY_AGENT} from '#/state/queries' import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {IS_DEV} from '#/env' import {emitSessionDropped} from '../events' -import {readLabelers} from './agent-config' - -let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT - -function __getAgent() { - return __globalAgent -} - -export function useAgent() { - return React.useMemo(() => ({getAgent: __getAgent}), []) -} +import { + agentToSessionAccount, + configureModerationForAccount, + configureModerationForGuest, + createAgentAndCreateAccount, + createAgentAndLogin, + isSessionDeactivated, + isSessionExpired, +} from './util' + +export type {SessionAccount} from '#/state/session/types' +import { + SessionAccount, + SessionApiContext, + SessionStateContext, +} from '#/state/session/types' -export type SessionAccount = persisted.PersistedAccount +export {isSessionDeactivated} -export type SessionState = { - isInitialLoad: boolean - isSwitchingAccounts: boolean - accounts: SessionAccount[] - currentAccount: SessionAccount | undefined -} -export type StateContext = SessionState & { - hasSession: boolean -} -export type ApiContext = { - createAccount: (props: { - service: string - email: string - password: string - handle: string - inviteCode?: string - verificationPhone?: string - verificationCode?: string - }) => Promise - login: ( - props: { - service: string - identifier: string - password: string - authFactorToken?: string | undefined - }, - logContext: LogEvents['account:loggedIn']['logContext'], - ) => Promise - /** - * A full logout. Clears the `currentAccount` from session, AND removes - * access tokens from all accounts, so that returning as any user will - * require a full login. - */ - logout: ( - logContext: LogEvents['account:loggedOut']['logContext'], - ) => Promise - /** - * A partial logout. Clears the `currentAccount` from session, but DOES NOT - * clear access tokens from accounts, allowing the user to return to their - * other accounts without logging in. - * - * Used when adding a new account, deleting an account. - */ - clearCurrentAccount: () => void - initSession: (account: SessionAccount) => Promise - resumeSession: (account?: SessionAccount) => Promise - removeAccount: (account: SessionAccount) => void - selectAccount: ( - account: SessionAccount, - logContext: LogEvents['account:loggedIn']['logContext'], - ) => Promise - updateCurrentAccount: ( - account: Partial< - Pick< - SessionAccount, - 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' - > - >, - ) => void -} +const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE}) +configureModerationForGuest() -const StateContext = React.createContext({ - isInitialLoad: true, - isSwitchingAccounts: false, +const StateContext = React.createContext({ accounts: [], currentAccount: undefined, hasSession: false, }) -const ApiContext = React.createContext({ +const ApiContext = React.createContext({ createAccount: async () => {}, login: async () => {}, logout: async () => {}, initSession: async () => {}, - resumeSession: async () => {}, removeAccount: () => {}, - selectAccount: async () => {}, updateCurrentAccount: () => {}, clearCurrentAccount: () => {}, }) -function createPersistSessionHandler( - agent: BskyAgent, - account: SessionAccount, - persistSessionCallback: (props: { - expired: boolean - refreshedAccount: SessionAccount - }) => void, - { - networkErrorCallback, - }: { - networkErrorCallback?: () => void - } = {}, -): AtpPersistSessionHandler { - return function persistSession(event, session) { - const expired = event === 'expired' || event === 'create-failed' - - if (event === 'network-error') { - logger.warn(`session: persistSessionHandler received network-error event`) - networkErrorCallback?.() - return - } - - const refreshedAccount: SessionAccount = { - service: account.service, - did: session?.did || account.did, - handle: session?.handle || account.handle, - email: session?.email || account.email, - emailConfirmed: session?.emailConfirmed || account.emailConfirmed, - deactivated: isSessionDeactivated(session?.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - - /* - * Tokens are undefined if the session expires, or if creation fails for - * any reason e.g. tokens are invalid, network error, etc. - */ - refreshJwt: session?.refreshJwt, - accessJwt: session?.accessJwt, - } +let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT - logger.debug(`session: persistSession`, { - event, - deactivated: refreshedAccount.deactivated, - }) +function __getAgent() { + return __globalAgent +} - if (expired) { - logger.warn(`session: expired`) - emitSessionDropped() - } +type AgentState = { + readonly agent: BskyAgent + readonly did: string | undefined +} - /* - * If the session expired, or it was successfully created/updated, we want - * to update/persist the data. - * - * If the session creation failed, it could be a network error, or it could - * be more serious like an invalid token(s). We can't differentiate, so in - * order to allow the user to get a fresh token (if they need it), we need - * to persist this data and wipe their tokens, effectively logging them - * out. - */ - persistSessionCallback({ - expired, - refreshedAccount, - }) - } +type State = { + accounts: SessionStateContext['accounts'] + currentAgentState: AgentState + needsPersist: boolean } export function Provider({children}: React.PropsWithChildren<{}>) { - const isDirty = React.useRef(false) - const [state, setState] = React.useState({ - isInitialLoad: true, - isSwitchingAccounts: false, + const [state, setState] = React.useState(() => ({ accounts: persisted.get('session').accounts, - currentAccount: undefined, // assume logged out to start - }) - - const setStateAndPersist = React.useCallback( - (fn: (prev: SessionState) => SessionState) => { - isDirty.current = true - setState(fn) + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, // assume logged out to start }, - [setState], - ) + needsPersist: false, + })) - const upsertAccount = React.useCallback( - (account: SessionAccount, expired = false) => { - setStateAndPersist(s => { + const clearCurrentAccount = React.useCallback(() => { + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + }, [setState]) + + const onAgentSessionChange = React.useCallback( + ( + agent: BskyAgent, + account: SessionAccount, + event: AtpSessionEvent, + session: AtpSessionData | undefined, + ) => { + const expired = event === 'expired' || event === 'create-failed' + + if (event === 'network-error') { + logger.warn( + `session: persistSessionHandler received network-error event`, + ) + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + return + } + + // TODO: use agentToSessionAccount for this too. + const refreshedAccount: SessionAccount = { + service: account.service, + did: session?.did ?? account.did, + handle: session?.handle ?? account.handle, + email: session?.email ?? account.email, + emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed, + emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor, + deactivated: isSessionDeactivated(session?.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), + + /* + * Tokens are undefined if the session expires, or if creation fails for + * any reason e.g. tokens are invalid, network error, etc. + */ + refreshJwt: session?.refreshJwt, + accessJwt: session?.accessJwt, + } + + logger.debug(`session: persistSession`, { + event, + deactivated: refreshedAccount.deactivated, + }) + + if (expired) { + logger.warn(`session: expired`) + emitSessionDropped() + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + agent: PUBLIC_BSKY_AGENT, + did: undefined, + }, + needsPersist: true, + })) + } + + /* + * If the session expired, or it was successfully created/updated, we want + * to update/persist the data. + * + * If the session creation failed, it could be a network error, or it could + * be more serious like an invalid token(s). We can't differentiate, so in + * order to allow the user to get a fresh token (if they need it), we need + * to persist this data and wipe their tokens, effectively logging them + * out. + */ + setState(s => { + const existingAccount = s.accounts.find( + a => a.did === refreshedAccount.did, + ) + if ( + !expired && + existingAccount && + refreshedAccount && + JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount) + ) { + // Fast path without a state update. + return s + } return { - ...s, - currentAccount: expired ? undefined : account, - accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + accounts: [ + refreshedAccount, + ...s.accounts.filter(a => a.did !== refreshedAccount.did), + ], + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], + [], ) - const clearCurrentAccount = React.useCallback(() => { - logger.warn(`session: clear current account`) - __globalAgent = PUBLIC_BSKY_AGENT - setStateAndPersist(s => ({ - ...s, - currentAccount: undefined, - })) - }, [setStateAndPersist]) - - const createAccount = React.useCallback( + const createAccount = React.useCallback( async ({ service, email, @@ -228,157 +202,109 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inviteCode, verificationPhone, verificationCode, - }: any) => { + }) => { logger.info(`session: creating account`) track('Try Create Account') logEvent('account:create:begin', {}) - - const agent = new BskyAgent({service}) - - await agent.createAccount({ - handle, - password, - email, - inviteCode, - verificationPhone, - verificationCode, - }) - - if (!agent.session) { - throw new Error(`session: createAccount failed to establish a session`) - } - const fetchingGates = tryFetchGates( - agent.session.did, - 'prefer-fresh-gates', + const {agent, account, fetchingGates} = await createAgentAndCreateAccount( + { + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }, ) - const deactivated = isSessionDeactivated(agent.session.accessJwt) - if (!deactivated) { - /*dont await*/ agent.upsertProfile(_existing => { - return { - displayName: '', - - // HACKFIX - // creating a bunch of identical profile objects is breaking the relay - // tossing this unspecced field onto it to reduce the size of the problem - // -prf - createdAt: new Date().toISOString(), - } - }) - } - - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email!, // TODO this is always defined? - emailConfirmed: false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated, - pdsUrl: agent.pdsUrl?.toString(), - } - - await configureModeration(agent, account) - - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') logEvent('account:create:success', {}) }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const login = React.useCallback( + const login = React.useCallback( async ({service, identifier, password, authFactorToken}, logContext) => { logger.debug(`session: login`, {}, logger.DebugContext.session) + const {agent, account, fetchingGates} = await createAgentAndLogin({ + service, + identifier, + password, + authFactorToken, + }) - const agent = new BskyAgent({service}) - - await agent.login({identifier, password, authFactorToken}) - - if (!agent.session) { - throw new Error(`session: login failed to establish a session`) - } - const fetchingGates = tryFetchGates( - agent.session.did, - 'prefer-fresh-gates', - ) - - const account: SessionAccount = { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - emailAuthFactor: agent.session.emailAuthFactor, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - } - - await configureModeration(agent, account) - - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) __globalAgent = agent // @ts-ignore if (IS_DEV && isWeb) window.agent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) logger.debug(`session: logged in`, {}, logger.DebugContext.session) track('Sign In', {resumedSession: false}) logEvent('account:loggedIn', {logContext, withPassword: true}) }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const logout = React.useCallback( + const logout = React.useCallback( async logContext => { logger.debug(`session: logout`) - clearCurrentAccount() - setStateAndPersist(s => { + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => { return { - ...s, accounts: s.accounts.map(a => ({ ...a, refreshJwt: undefined, accessJwt: undefined, })), + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, + needsPersist: true, } }) logEvent('account:loggedOut', {logContext}) }, - [clearCurrentAccount, setStateAndPersist], + [setState], ) - const initSession = React.useCallback( + const initSession = React.useCallback( async account => { logger.debug(`session: initSession`, {}, logger.DebugContext.session) const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency') @@ -390,55 +316,65 @@ export function Provider({children}: React.PropsWithChildren<{}>) { agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl) } - agent.setPersistSessionHandler( - createPersistSessionHandler( - agent, - account, - ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }, - {networkErrorCallback: clearCurrentAccount}, - ), - ) + agent.setPersistSessionHandler((event, session) => { + onAgentSessionChange(agent, account, event, session) + }) // @ts-ignore if (IS_DEV && isWeb) window.agent = agent - await configureModeration(agent, account) - - let canReusePrevSession = false - try { - if (account.accessJwt) { - const decoded = jwtDecode(account.accessJwt) - if (decoded.exp) { - const didExpire = Date.now() >= decoded.exp * 1000 - if (!didExpire) { - canReusePrevSession = true - } - } - } - } catch (e) { - logger.error(`session: could not decode jwt`) - } + await configureModerationForAccount(agent, account) + + const accountOrSessionDeactivated = + isSessionDeactivated(account.accessJwt) || account.deactivated const prevSession = { - accessJwt: account.accessJwt || '', - refreshJwt: account.refreshJwt || '', + accessJwt: account.accessJwt ?? '', + refreshJwt: account.refreshJwt ?? '', did: account.did, handle: account.handle, - deactivated: - isSessionDeactivated(account.accessJwt) || account.deactivated, } - if (canReusePrevSession) { + if (isSessionExpired(account)) { + logger.debug(`session: attempting to resume using previous session`) + + const freshAccount = await resumeSessionWithFreshAccount() + __globalAgent = agent + await fetchingGates + setState(s => { + return { + accounts: [ + freshAccount, + ...s.accounts.filter(a => a.did !== freshAccount.did), + ], + currentAgentState: { + did: freshAccount.did, + agent: agent, + }, + needsPersist: true, + } + }) + } else { logger.debug(`session: attempting to reuse previous session`) agent.session = prevSession __globalAgent = agent await fetchingGates - upsertAccount(account) + setState(s => { + return { + accounts: [ + account, + ...s.accounts.filter(a => a.did !== account.did), + ], + currentAgentState: { + did: account.did, + agent: agent, + }, + needsPersist: true, + } + }) - if (prevSession.deactivated) { + if (accountOrSessionDeactivated) { // don't attempt to resume // use will be taken to the deactivated screen logger.debug(`session: reusing session for deactivated account`) @@ -447,191 +383,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // Intentionally not awaited to unblock the UI: resumeSessionWithFreshAccount() - .then(freshAccount => { - if (JSON.stringify(account) !== JSON.stringify(freshAccount)) { - logger.info( - `session: reuse of previous session returned a fresh account, upserting`, - ) - upsertAccount(freshAccount) - } - }) - .catch(e => { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - }) - } else { - logger.debug(`session: attempting to resume using previous session`) - - try { - const freshAccount = await resumeSessionWithFreshAccount() - __globalAgent = agent - await fetchingGates - upsertAccount(freshAccount) - } catch (e) { - /* - * Note: `agent.persistSession` is also called when this fails, and - * we handle that failure via `createPersistSessionHandler` - */ - logger.info(`session: resumeSessionWithFreshAccount failed`, { - message: e, - }) - - __globalAgent = PUBLIC_BSKY_AGENT - } } async function resumeSessionWithFreshAccount(): Promise { logger.debug(`session: resumeSessionWithFreshAccount`) await networkRetry(1, () => agent.resumeSession(prevSession)) - + const sessionAccount = agentToSessionAccount(agent) /* * If `agent.resumeSession` fails above, it'll throw. This is just to * make TypeScript happy. */ - if (!agent.session) { + if (!sessionAccount) { throw new Error(`session: initSession failed to establish a session`) } - - // ensure changes in handle/email etc are captured on reload - return { - service: agent.service.toString(), - did: agent.session.did, - handle: agent.session.handle, - email: agent.session.email, - emailConfirmed: agent.session.emailConfirmed || false, - emailAuthFactor: agent.session.emailAuthFactor || false, - refreshJwt: agent.session.refreshJwt, - accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), - pdsUrl: agent.pdsUrl?.toString(), - } + return sessionAccount } }, - [upsertAccount, clearCurrentAccount], + [onAgentSessionChange], ) - const resumeSession = React.useCallback( - async account => { - try { - if (account) { - await initSession(account) - } - } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) - } finally { - setState(s => ({ - ...s, - isInitialLoad: false, - })) - } - }, - [initSession], - ) - - const removeAccount = React.useCallback( + const removeAccount = React.useCallback( account => { - setStateAndPersist(s => { + setState(s => { return { - ...s, accounts: s.accounts.filter(a => a.did !== account.did), + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], + [setState], ) const updateCurrentAccount = React.useCallback< - ApiContext['updateCurrentAccount'] + SessionApiContext['updateCurrentAccount'] >( account => { - setStateAndPersist(s => { - const currentAccount = s.currentAccount - + setState(s => { + const currentAccount = s.accounts.find( + a => a.did === s.currentAgentState.did, + ) // ignore, should never happen if (!currentAccount) return s const updatedAccount = { ...currentAccount, - handle: account.handle || currentAccount.handle, - email: account.email || currentAccount.email, + handle: account.handle ?? currentAccount.handle, + email: account.email ?? currentAccount.email, emailConfirmed: - account.emailConfirmed !== undefined - ? account.emailConfirmed - : currentAccount.emailConfirmed, + account.emailConfirmed ?? currentAccount.emailConfirmed, emailAuthFactor: - account.emailAuthFactor !== undefined - ? account.emailAuthFactor - : currentAccount.emailAuthFactor, + account.emailAuthFactor ?? currentAccount.emailAuthFactor, } return { - ...s, - currentAccount: updatedAccount, accounts: [ updatedAccount, ...s.accounts.filter(a => a.did !== currentAccount.did), ], + currentAgentState: s.currentAgentState, + needsPersist: true, } }) }, - [setStateAndPersist], - ) - - const selectAccount = React.useCallback( - async (account, logContext) => { - setState(s => ({...s, isSwitchingAccounts: true})) - try { - await initSession(account) - setState(s => ({...s, isSwitchingAccounts: false})) - logEvent('account:loggedIn', {logContext, withPassword: false}) - } catch (e) { - // reset this in case of error - setState(s => ({...s, isSwitchingAccounts: false})) - // but other listeners need a throw - throw e - } - }, - [setState, initSession], + [setState], ) React.useEffect(() => { - if (isDirty.current) { - isDirty.current = false + if (state.needsPersist) { + state.needsPersist = false persisted.write('session', { accounts: state.accounts, - currentAccount: state.currentAccount, + currentAccount: state.accounts.find( + a => a.did === state.currentAgentState.did, + ), }) } }, [state]) React.useEffect(() => { return persisted.onUpdate(() => { - const session = persisted.get('session') + const persistedSession = persisted.get('session') logger.debug(`session: persisted onUpdate`, {}) + setState(s => ({ + accounts: persistedSession.accounts, + currentAgentState: s.currentAgentState, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) + + const selectedAccount = persistedSession.accounts.find( + a => a.did === persistedSession.currentAccount?.did, + ) - if (session.currentAccount && session.currentAccount.refreshJwt) { - if (session.currentAccount?.did !== state.currentAccount?.did) { + if (selectedAccount && selectedAccount.refreshJwt) { + if (selectedAccount.did !== state.currentAgentState.did) { logger.debug(`session: persisted onUpdate, switching accounts`, { from: { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, + did: state.currentAgentState.did, }, to: { - did: session.currentAccount.did, - handle: session.currentAccount.handle, + did: selectedAccount.did, }, }) - initSession(session.currentAccount) + initSession(selectedAccount) } else { logger.debug(`session: persisted onUpdate, updating session`, {}) @@ -641,9 +498,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * already persisted, and we'll get a loop between tabs. */ // @ts-ignore we checked for `refreshJwt` above - __globalAgent.session = session.currentAccount + __globalAgent.session = selectedAccount + // TODO: This needs a setState. } - } else if (!session.currentAccount && state.currentAccount) { + } else if (!selectedAccount && state.currentAgentState.did) { logger.debug( `session: persisted onUpdate, logging out`, {}, @@ -656,21 +514,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * handled by `persistSession` (which nukes this accounts tokens only), * or by a `logout` call which nukes all accounts tokens) */ - clearCurrentAccount() + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + configureModerationForGuest() + setState(s => ({ + accounts: s.accounts, + currentAgentState: { + did: undefined, + agent: PUBLIC_BSKY_AGENT, + }, + needsPersist: false, // Synced from another tab. Don't persist to avoid cycles. + })) } - - setState(s => ({ - ...s, - accounts: session.accounts, - currentAccount: session.currentAccount, - })) }) - }, [state, setState, clearCurrentAccount, initSession]) + }, [state, setState, initSession]) const stateContext = React.useMemo( () => ({ - ...state, - hasSession: !!state.currentAccount, + accounts: state.accounts, + currentAccount: state.accounts.find( + a => a.did === state.currentAgentState.did, + ), + hasSession: !!state.currentAgentState.did, }), [state], ) @@ -681,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, }), @@ -692,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { login, logout, initSession, - resumeSession, removeAccount, - selectAccount, updateCurrentAccount, clearCurrentAccount, ], @@ -707,28 +568,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -async function configureModeration(agent: BskyAgent, account: SessionAccount) { - if (IS_TEST_USER(account.handle)) { - const did = ( - await agent - .resolveHandle({handle: 'mod-authority.test'}) - .catch(_ => undefined) - )?.data.did - if (did) { - console.warn('USING TEST ENV MODERATION') - BskyAgent.configure({appLabelers: [did]}) - } - } else { - BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) - const labelerDids = await readLabelers(account.did).catch(_ => {}) - if (labelerDids) { - agent.configureLabelersHeader( - labelerDids.filter(did => did !== BSKY_LABELER_DID), - ) - } - } -} - export function useSession() { return React.useContext(StateContext) } @@ -755,12 +594,6 @@ export function useRequireAuth() { ) } -export function isSessionDeactivated(accessJwt: string | undefined) { - if (accessJwt) { - const sessData = jwtDecode(accessJwt) - return ( - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' - ) - } - return false +export function useAgent() { + return React.useMemo(() => ({getAgent: __getAgent}), []) } diff --git a/src/state/session/types.ts b/src/state/session/types.ts new file mode 100644 index 0000000000..b3252f7776 --- /dev/null +++ b/src/state/session/types.ts @@ -0,0 +1,56 @@ +import {LogEvents} from '#/lib/statsig/statsig' +import {PersistedAccount} from '#/state/persisted' + +export type SessionAccount = PersistedAccount + +export type SessionStateContext = { + accounts: SessionAccount[] + currentAccount: SessionAccount | undefined + hasSession: boolean +} +export type SessionApiContext = { + createAccount: (props: { + service: string + email: string + password: string + handle: string + inviteCode?: string + verificationPhone?: string + verificationCode?: string + }) => Promise + login: ( + props: { + service: string + identifier: string + password: string + authFactorToken?: string | undefined + }, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => Promise + /** + * A full logout. Clears the `currentAccount` from session, AND removes + * access tokens from all accounts, so that returning as any user will + * require a full login. + */ + logout: ( + logContext: LogEvents['account:loggedOut']['logContext'], + ) => Promise + /** + * A partial logout. Clears the `currentAccount` from session, but DOES NOT + * clear access tokens from accounts, allowing the user to return to their + * other accounts without logging in. + * + * Used when adding a new account, deleting an account. + */ + clearCurrentAccount: () => void + initSession: (account: SessionAccount) => Promise + removeAccount: (account: SessionAccount) => void + updateCurrentAccount: ( + account: Partial< + Pick< + SessionAccount, + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' + > + >, + ) => void +} diff --git a/src/state/session/util/index.ts b/src/state/session/util/index.ts new file mode 100644 index 0000000000..e3e246f7b3 --- /dev/null +++ b/src/state/session/util/index.ts @@ -0,0 +1,177 @@ +import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' +import {jwtDecode} from 'jwt-decode' + +import {IS_TEST_USER} from '#/lib/constants' +import {tryFetchGates} from '#/lib/statsig/statsig' +import {hasProp} from '#/lib/type-guards' +import {logger} from '#/logger' +import * as persisted from '#/state/persisted' +import {readLabelers} from '../agent-config' +import {SessionAccount, SessionApiContext} from '../types' + +export function isSessionDeactivated(accessJwt: string | undefined) { + if (accessJwt) { + const sessData = jwtDecode(accessJwt) + return ( + hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + ) + } + return false +} + +export function readLastActiveAccount() { + const {currentAccount, accounts} = persisted.get('session') + return accounts.find(a => a.did === currentAccount?.did) +} + +export function agentToSessionAccount( + agent: BskyAgent, +): SessionAccount | undefined { + if (!agent.session) return undefined + + return { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email, + emailConfirmed: agent.session.emailConfirmed || false, + emailAuthFactor: agent.session.emailAuthFactor || false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + deactivated: isSessionDeactivated(agent.session.accessJwt), + pdsUrl: agent.pdsUrl?.toString(), + } +} + +export function configureModerationForGuest() { + switchToBskyAppLabeler() +} + +export async function configureModerationForAccount( + agent: BskyAgent, + account: SessionAccount, +) { + switchToBskyAppLabeler() + if (IS_TEST_USER(account.handle)) { + await trySwitchToTestAppLabeler(agent) + } + + const labelerDids = await readLabelers(account.did).catch(_ => {}) + if (labelerDids) { + agent.configureLabelersHeader( + labelerDids.filter(did => did !== BSKY_LABELER_DID), + ) + } else { + // If there are no headers in the storage, we'll not send them on the initial requests. + // If we wanted to fix this, we could block on the preferences query here. + } +} + +function switchToBskyAppLabeler() { + BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) +} + +async function trySwitchToTestAppLabeler(agent: BskyAgent) { + const did = ( + await agent + .resolveHandle({handle: 'mod-authority.test'}) + .catch(_ => undefined) + )?.data.did + if (did) { + console.warn('USING TEST ENV MODERATION') + BskyAgent.configure({appLabelers: [did]}) + } +} + +export function isSessionExpired(account: SessionAccount) { + try { + if (account.accessJwt) { + const decoded = jwtDecode(account.accessJwt) + if (decoded.exp) { + const didExpire = Date.now() >= decoded.exp * 1000 + return didExpire + } + } + } catch (e) { + logger.error(`session: could not decode jwt`) + } + return true +} + +export async function createAgentAndLogin({ + service, + identifier, + password, + authFactorToken, +}: { + service: string + identifier: string + password: string + authFactorToken?: string +}) { + const agent = new BskyAgent({service}) + await agent.login({identifier, password, authFactorToken}) + + const account = agentToSessionAccount(agent) + if (!agent.session || !account) { + throw new Error(`session: login failed to establish a session`) + } + + const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') + await configureModerationForAccount(agent, account) + + return { + agent, + account, + fetchingGates, + } +} + +export async function createAgentAndCreateAccount({ + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, +}: Parameters[0]) { + const agent = new BskyAgent({service}) + await agent.createAccount({ + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }) + + const account = agentToSessionAccount(agent)! + if (!agent.session || !account) { + throw new Error(`session: createAccount failed to establish a session`) + } + + const fetchingGates = tryFetchGates(account.did, 'prefer-fresh-gates') + + if (!account.deactivated) { + /*dont await*/ agent.upsertProfile(_existing => { + return { + displayName: '', + + // HACKFIX + // creating a bunch of identical profile objects is breaking the relay + // tossing this unspecced field onto it to reduce the size of the problem + // -prf + createdAt: new Date().toISOString(), + } + }) + } + + await configureModerationForAccount(agent, account) + + return { + agent, + account, + fetchingGates, + } +} diff --git a/src/state/session/util/readLastActiveAccount.ts b/src/state/session/util/readLastActiveAccount.ts deleted file mode 100644 index e0768b8a83..0000000000 --- a/src/state/session/util/readLastActiveAccount.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as persisted from '#/state/persisted' - -export function readLastActiveAccount() { - const {currentAccount, accounts} = persisted.get('session') - return accounts.find(a => a.did === currentAccount?.did) -} diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index f905e1e8d5..6df4e439aa 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -4,6 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' @@ -28,6 +29,8 @@ export const SplashScreen = ({ const t = useTheme() const {isTabletOrMobile: isMobileWeb} = useWebMediaQueries() + const kawaii = useKawaiiMode() + return ( <> {onDismiss && ( @@ -66,11 +69,13 @@ export const SplashScreen = ({ ]}> - + - - - + {!kawaii && ( + + + + )} { + setExtLink(ext => + ext && ext.meta + ? { + ...ext, + meta: { + ...ext?.meta, + description: + altText.trim().length === 0 + ? '' + : `Alt text: ${altText.trim()}`, + }, + } + : ext, + ) + }, + [setExtLink], + ) + return ( {gallery.isEmpty && extLink && ( - { - setExtLink(undefined) - setExtGif(undefined) - }} - /> + + { + setExtLink(undefined) + setExtGif(undefined) + }} + /> + + )} {quote ? ( diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 321e29b30a..b81065e99d 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -46,7 +46,12 @@ export const ExternalEmbed = ({ : undefined return ( - + {link.isLoading ? ( @@ -62,7 +67,7 @@ export const ExternalEmbed = ({ ) : linkInfo ? ( - + ) : null} void +}) { + const control = Dialog.useDialogControl() + const {_} = useLingui() + const t = useTheme() + + const {link, params} = React.useMemo(() => { + return { + link: { + title: linkProp.meta?.title ?? linkProp.uri, + uri: linkProp.uri, + description: linkProp.meta?.description ?? '', + thumb: linkProp.localThumb?.path, + }, + params: parseEmbedPlayerFromUrl(linkProp.uri), + } + }, [linkProp]) + + const onPressSubmit = useCallback( + (alt: string) => { + control.close(() => { + onSubmit(alt) + }) + }, + [onSubmit, control], + ) + + if (!gif || !params) return null + + return ( + <> + + {link.description ? ( + + ) : ( + + )} + + ALT + + + + + + + + + + + ) +} + +function AltTextInner({ + onSubmit, + link, + params, + initalValue, +}: { + onSubmit: (text: string) => void + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams + initalValue: string +}) { + const {_} = useLingui() + const [altText, setAltText] = useState(initalValue) + + const onPressSubmit = useCallback(() => { + onSubmit(altText) + }, [onSubmit, altText]) + + return ( + + + + + + Descriptive alt text + + + + setAltText(enforceLen(text, MAX_ALT_TEXT)) + } + value={altText} + multiline + numberOfLines={3} + autoFocus + /> + + + + + {/* below the text input to force tab order */} + + + Add ALT text + + + + + + + + + ) +} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 69c8debb02..7ff1b7b9ab 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,19 +1,20 @@ import React, {useState} from 'react' import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' -import {GalleryModel} from 'state/models/media/gallery' -import {observer} from 'mobx-react-lite' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' -import {Text} from 'view/com/util/text/Text' -import {Dimensions} from 'lib/media/types' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {Trans, msg} from '@lingui/macro' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {observer} from 'mobx-react-lite' + import {useModalControls} from '#/state/modals' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Dimensions} from 'lib/media/types' +import {colors, s} from 'lib/styles' import {isNative} from 'platform/detection' +import {GalleryModel} from 'state/models/media/gallery' +import {Text} from 'view/com/util/text/Text' +import {useTheme} from '#/alf' const IMAGE_GAP = 8 @@ -49,10 +50,10 @@ const GalleryInner = observer(function GalleryImpl({ gallery, containerInfo, }: GalleryInnerProps) { - const pal = usePalette('default') const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() + const t = useTheme() let side: number @@ -126,16 +127,22 @@ const GalleryInner = observer(function GalleryImpl({ }) }} style={[styles.altTextControl, altTextControlStyle]}> - - ALT - {image.altText.length > 0 ? ( + ) : ( + - ) : undefined} + )} + + ALT + ))} - - - - - - - Alt text describes images for blind and low-vision users, and helps - give context to everyone. - - - + ) : null }) +export function AltTextReminder() { + const t = useTheme() + return ( + + + + + + + Alt text describes images for blind and low-vision users, and helps + give context to everyone. + + + + ) +} + const styles = StyleSheet.create({ gallery: { flex: 1, @@ -244,6 +258,7 @@ const styles = StyleSheet.create({ paddingVertical: 3, flexDirection: 'row', alignItems: 'center', + gap: 4, }, altTextControlLabel: { color: 'white', diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 644d4cab6c..f00a15b3f4 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -15,6 +15,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Logo} from '#/view/icons/Logo' +import {useKawaiiMode} from '../../../state/preferences/kawaii' import {Link} from '../util/Link' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' @@ -43,10 +44,19 @@ function HomeHeaderLayoutDesktopAndTablet({ const {hasSession} = useSession() const {_} = useLingui() + const kawaii = useKawaiiMode() + return ( <> {hasSession && ( - + - + = Omit< refreshing?: boolean onRefresh?: () => void onItemSeen?: (item: ItemT) => void + containWeb?: boolean } export type ListRef = React.MutableRefObject @@ -46,7 +45,6 @@ function ListImpl( const isScrolledDown = useSharedValue(false) const contextScrollHandlers = useScrollHandlers() const pal = usePalette('default') - const gate = useGate() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -129,9 +127,6 @@ function ListImpl( viewabilityConfig={viewabilityConfig} style={style} ref={ref} - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> ) } diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 5b7328fa75..47121856a3 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -1,5 +1,6 @@ import React, {isValidElement, memo, startTransition, useRef} from 'react' import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {batchedUpdates} from '#/lib/batchedUpdates' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -21,6 +22,7 @@ export type ListProps = Omit< onRefresh?: () => void onItemSeen?: (item: ItemT) => void desktopFixedHeight: any // TODO: Better types. + containWeb?: boolean } export type ListRef = React.MutableRefObject // TODO: Better types. @@ -33,12 +35,15 @@ function ListImpl( { ListHeaderComponent, ListFooterComponent, + containWeb, contentContainerStyle, data, desktopFixedHeight, headerOffset, keyExtractor, refreshing: _unsupportedRefreshing, + onStartReached, + onStartReachedThreshold = 0, onEndReached, onEndReachedThreshold = 0, onRefresh: _unsupportedOnRefresh, @@ -88,14 +93,88 @@ function ListImpl( }) } - const nativeRef = React.useRef(null) + const getScrollableNode = React.useCallback(() => { + if (containWeb) { + const element = nativeRef.current as HTMLDivElement | null + if (!element) return + + return { + get scrollWidth() { + return element.scrollWidth + }, + get scrollHeight() { + return element.scrollHeight + }, + get clientWidth() { + return element.clientWidth + }, + get clientHeight() { + return element.clientHeight + }, + get scrollY() { + return element.scrollTop + }, + get scrollX() { + return element.scrollLeft + }, + scrollTo(options?: ScrollToOptions) { + element.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + element.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + element.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + element.removeEventListener(event, handler) + }, + } + } else { + return { + get scrollWidth() { + return document.documentElement.scrollWidth + }, + get scrollHeight() { + return document.documentElement.scrollHeight + }, + get clientWidth() { + return window.innerWidth + }, + get clientHeight() { + return window.innerHeight + }, + get scrollY() { + return window.scrollY + }, + get scrollX() { + return window.scrollX + }, + scrollTo(options: ScrollToOptions) { + window.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + window.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + window.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + window.removeEventListener(event, handler) + }, + } + } + }, [containWeb]) + + const nativeRef = React.useRef(null) React.useImperativeHandle( ref, () => ({ scrollToTop() { - window.scrollTo({top: 0}) + getScrollableNode()?.scrollTo({top: 0}) }, + scrollToOffset({ animated, offset, @@ -103,46 +182,74 @@ function ListImpl( animated: boolean offset: number }) { - window.scrollTo({ + getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, + scrollToEnd({animated = true}: {animated?: boolean}) { + const element = getScrollableNode() + element?.scrollTo({ + left: 0, + top: element.scrollHeight, + behavior: animated ? 'smooth' : 'instant', + }) + }, } as any), // TODO: Better types. - [], + [getScrollableNode], ) - // --- onContentSizeChange --- + // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) - const handleWindowScroll = useNonReactiveCallback(() => { - if (isInsideVisibleTree) { - contextScrollHandlers.onScroll?.( - { - contentOffset: { - x: Math.max(0, window.scrollX), - y: Math.max(0, window.scrollY), - }, - } as any, // TODO: Better types. - null as any, - ) - } + const handleScroll = useNonReactiveCallback(() => { + if (!isInsideVisibleTree) return + + const element = getScrollableNode() + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, element?.scrollX ?? 0), + y: Math.max(0, element?.scrollY ?? 0), + }, + layoutMeasurement: { + width: element?.clientWidth, + height: element?.clientHeight, + }, + contentSize: { + width: element?.scrollWidth, + height: element?.scrollHeight, + }, + } as Exclude< + ReanimatedScrollEvent, + | 'velocity' + | 'eventName' + | 'zoomScale' + | 'targetContentOffset' + | 'contentInset' + >, + null as any, + ) }) + React.useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } - window.addEventListener('scroll', handleWindowScroll) + + const element = getScrollableNode() + + element?.addEventListener('scroll', handleScroll) return () => { - window.removeEventListener('scroll', handleWindowScroll) + element?.removeEventListener('scroll', handleScroll) } - }, [isInsideVisibleTree, handleWindowScroll]) + }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) @@ -156,6 +263,17 @@ function ListImpl( } } + // --- onStartReached --- + const onHeadVisibilityChange = useNonReactiveCallback( + (isHeadVisible: boolean) => { + if (isHeadVisible) { + onStartReached?.({ + distanceFromStart: onStartReachedThreshold || 0, + }) + } + }, + ) + // --- onEndReached --- const onTailVisibilityChange = useNonReactiveCallback( (isTailVisible: boolean) => { @@ -168,7 +286,17 @@ function ListImpl( ) return ( - + ( pal.border, ]}> + {onStartReached && ( + + )} {header} {(data as Array).map((item, index) => { const key = keyExtractor!(item, index) @@ -205,8 +341,9 @@ function ListImpl( })} {onEndReached && ( )} {footer} @@ -311,11 +448,15 @@ let Row = function RowImpl({ Row = React.memo(Row) let Visibility = ({ + root, topMargin = '0px', + bottomMargin = '0px', onVisibleChange, style, }: { + root?: React.RefObject | null topMargin?: string + bottomMargin?: string onVisibleChange: (isVisible: boolean) => void style?: ViewProps['style'] }): React.ReactNode => { @@ -337,14 +478,15 @@ let Visibility = ({ React.useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { - rootMargin: `${topMargin} 0px 0px 0px`, + root: root?.current ?? null, + rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current! observer.observe(tail) return () => { observer.unobserve(tail) } - }, [handleIntersection, topMargin]) + }, [bottomMargin, handleIntersection, topMargin, root]) return ( diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 02b0f2314b..a5d3a53722 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -6,17 +6,21 @@ import {ago} from 'lib/strings/time' export function TimeElapsed({ timestamp, children, + timeToString = ago, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element + timeToString?: (timeElapsed: string) => string }) { const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => ago(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + timeToString(timestamp), + ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(ago(timestamp)) + setTimeAgo(timeToString(timestamp)) } return children({timeElapsed}) diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx index 75f2b50814..2984a2d2d1 100644 --- a/src/view/com/util/Views.jsx +++ b/src/view/com/util/Views.jsx @@ -2,19 +2,11 @@ import React from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' -import {useGate} from 'lib/statsig/statsig' - export const FlatList_INTERNAL = Animated.FlatList export function CenteredView(props) { return } export function ScrollView(props) { - const gate = useGate() - return ( - - ) + return } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 51e65497e5..74c3995ab6 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,6 @@ import React, {memo} from 'react' import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' -import {setStringAsync} from 'expo-clipboard' +import * as Clipboard from 'expo-clipboard' import { AppBskyActorDefs, AppBskyFeedPost, @@ -168,7 +168,7 @@ let PostDropdownBtn = ({ const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) - setStringAsync(str) + Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`)) }, [_, richText]) diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 7de3b093ae..f6d2c7a1b0 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,9 +1,10 @@ -import {AppBskyEmbedImages} from '@atproto/api' import React, {ComponentProps, FC} from 'react' -import {StyleSheet, Text, Pressable, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import {Image} from 'expo-image' +import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' + import {isWeb} from 'platform/detection' type EventFunction = (index: number) => void diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 9f6c3f07e3..3b2a12c24b 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -21,10 +21,12 @@ export const ExternalLinkEmbed = ({ link, onOpen, style, + hideAlt, }: { link: AppBskyEmbedExternal.ViewExternal onOpen?: () => void style?: StyleProp + hideAlt?: boolean }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() @@ -39,7 +41,7 @@ export const ExternalLinkEmbed = ({ }, [link.uri, externalEmbedPrefs]) if (embedPlayerParams?.source === 'tenor') { - return + return } return ( diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 5d21ce0642..286b57992b 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -1,14 +1,18 @@ import React from 'react' -import {Pressable, View} from 'react-native' +import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' import {AppBskyEmbedExternal} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HITSLOP_10} from '#/lib/constants' +import {isWeb} from '#/platform/detection' import {EmbedPlayerParams} from 'lib/strings/embed-player' import {useAutoplayDisabled} from 'state/preferences' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' @@ -82,9 +86,11 @@ function PlaybackControls({ export function GifEmbed({ params, link, + hideAlt, }: { params: EmbedPlayerParams link: AppBskyEmbedExternal.ViewExternal + hideAlt?: boolean }) { const {_} = useLingui() const autoplayDisabled = useAutoplayDisabled() @@ -111,7 +117,8 @@ export function GifEmbed({ }, []) return ( - + + + {!hideAlt && link.description.startsWith('Alt text: ') && ( + + )} ) } + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + + const {_} = useLingui() + return ( + <> + + + ALT + + + + + + Alt Text + + {text} + + + + + + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + // Related to margin/gap hack. This keeps the alt label in the same position + // on all platforms + left: isWeb ? 8 : 5, + bottom: isWeb ? 8 : 5, + zIndex: 2, + }, + alt: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, +}) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 1ea1aa70f8..57f1d28ba6 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {s} from '#/lib/styles' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx index 9212381a9e..4de7c1613c 100644 --- a/src/view/icons/Logo.tsx +++ b/src/view/icons/Logo.tsx @@ -1,15 +1,17 @@ import React from 'react' import {StyleSheet, TextProps} from 'react-native' import Svg, { - Path, Defs, LinearGradient, + Path, + PathProps, Stop, SvgProps, - PathProps, } from 'react-native-svg' +import {Image} from 'expo-image' import {colors} from '#/lib/styles' +import {useKawaiiMode} from '#/state/preferences/kawaii' const ratio = 57 / 64 @@ -25,6 +27,25 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3 // @ts-ignore it's fiiiiine const size = parseInt(rest.width || 32) + + const isKawaii = useKawaiiMode() + + if (isKawaii) { + return ( + 100 + ? require('../../../assets/kawaii.png') + : require('../../../assets/kawaii_smol.png') + } + accessibilityLabel="Bluesky" + accessibilityHint="" + accessibilityIgnoresInvertColors + style={[{height: size, aspectRatio: 1.4}]} + /> + ) + } + return ( { - const listener = AppState.addEventListener('change', nextAppState => { - if (nextAppState === 'active') { - if ( - isMobile && - mode.value === 1 && - gate('disable_min_shell_on_foregrounding_v2') - ) { - setMinimalShellMode(false) + useFocusEffect( + React.useCallback(() => { + const listener = AppState.addEventListener('change', nextAppState => { + if (nextAppState === 'active') { + if ( + isMobile && + mode.value === 1 && + gate('disable_min_shell_on_foregrounding_v3') + ) { + setMinimalShellMode(false) + } } + }) + return () => { + listener.remove() } - }) - return () => { - listener.remove() - } - }, [setMinimalShellMode, mode, isMobile, gate]) + }, [setMinimalShellMode, mode, isMobile, gate]), + ) const onPageSelected = React.useCallback( (index: number) => { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index b7ce8cdd00..ebd9bb23e9 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {CommonNavigatorParams} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' -import {isWeb} from 'platform/detection' import {ProfileCard} from 'view/com/profile/ProfileCard' import {CenteredView} from 'view/com/util/Views' import {ErrorScreen} from '../com/util/error/ErrorScreen' @@ -38,7 +36,6 @@ export function ModerationBlockedAccounts({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const gate = useGate() const [isPTRing, setIsPTRing] = React.useState(false) const { @@ -168,9 +165,6 @@ export function ModerationBlockedAccounts({}: Props) { )} // @ts-ignore our .web version only -prf desktopFixedHeight - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> )} diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 4d7ca62946..e395a3a5ba 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {CommonNavigatorParams} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' -import {isWeb} from 'platform/detection' import {ProfileCard} from 'view/com/profile/ProfileCard' import {CenteredView} from 'view/com/util/Views' import {ErrorScreen} from '../com/util/error/ErrorScreen' @@ -38,7 +36,6 @@ export function ModerationMutedAccounts({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const gate = useGate() const [isPTRing, setIsPTRing] = React.useState(false) const { @@ -167,9 +164,6 @@ export function ModerationMutedAccounts({}: Props) { )} // @ts-ignore our .web version only -prf desktopFixedHeight - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> )} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 02d7c90fb4..4fa46a4cf1 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -13,9 +13,9 @@ import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLabelerInfoQuery} from '#/state/queries/labeler' import {resetProfilePostsQueries} from '#/state/queries/post-feed' -import {useModerationOpts} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useAgent, useSession} from '#/state/session' diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 1d93a9fd7d..2902ccf5eb 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -454,33 +454,29 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }, }) } - if (isCurateList) { + if (isCurateList && (isBlocking || isMuting)) { items.push({label: 'separator'}) - if (!isBlocking) { + if (isMuting) { items.push({ testID: 'listHeaderDropdownMuteBtn', - label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), - onPress: isMuting - ? onUnsubscribeMute - : subscribeMutePromptControl.open, + label: _(msg`Un-mute list`), + onPress: onUnsubscribeMute, icon: { ios: { - name: isMuting ? 'eye' : 'eye.slash', + name: 'eye', }, android: '', - web: isMuting ? 'eye' : ['far', 'eye-slash'], + web: 'eye', }, }) } - if (!isMuting) { + if (isBlocking) { items.push({ testID: 'listHeaderDropdownBlockBtn', - label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), - onPress: isBlocking - ? onUnsubscribeBlock - : subscribeBlockPromptControl.open, + label: _(msg`Un-block list`), + onPress: onUnsubscribeBlock, icon: { ios: { name: 'person.fill.xmark', @@ -508,9 +504,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { isBlocking, isMuting, onUnsubscribeMute, - subscribeMutePromptControl.open, onUnsubscribeBlock, - subscribeBlockPromptControl.open, ]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 9e34067fb8..9dd1c397f3 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -27,9 +27,9 @@ import {s} from '#/lib/styles' import {logger} from '#/logger' import {isIOS, isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' -import {useModerationOpts} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 6b5390c293..c3864e5a91 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -1,7 +1,5 @@ import React from 'react' import { - ActivityIndicator, - Linking, Platform, Pressable, StyleSheet, @@ -41,7 +39,7 @@ import { import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {useAnalytics} from 'lib/analytics/analytics' -import * as AppInfo from 'lib/app-info' +import {appVersion, BUNDLE_DATE, bundleInfo} from 'lib/app-info' import {STATUS_PAGE_URL} from 'lib/constants' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {useCustomPalette} from 'lib/hooks/useCustomPalette' @@ -61,23 +59,40 @@ import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' import {ScrollView} from 'view/com/util/Views' +import {useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {navigate, resetToTab} from '#/Navigation' import {Email2FAToggle} from './Email2FAToggle' import {ExportCarDialog} from './ExportCarDialog' -function SettingsAccountCard({account}: {account: SessionAccount}) { +function SettingsAccountCard({ + account, + pendingDid, + onPressSwitchAccount, +}: { + account: SessionAccount + pendingDid: string | null + onPressSwitchAccount: ( + account: SessionAccount, + logContext: 'Settings', + ) => void +}) { const pal = usePalette('default') const {_} = useLingui() - const {isSwitchingAccounts, currentAccount} = useSession() + const t = useTheme() + const {currentAccount} = useSession() const {logout} = useSessionApi() const {data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did - const {onPressSwitchAccount} = useAccountSwitcher() const contents = ( - + + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`} + activeOpacity={0.8}> Sign out @@ -135,13 +151,12 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { testID={`switchToAccountBtn-${account.handle}`} key={account.did} onPress={ - isSwitchingAccounts - ? undefined - : () => onPressSwitchAccount(account, 'Settings') + pendingDid ? undefined : () => onPressSwitchAccount(account, 'Settings') } accessibilityRole="button" accessibilityLabel={_(msg`Switch to ${account.handle}`)} - accessibilityHint={_(msg`Switches the account you are logged in to`)}> + accessibilityHint={_(msg`Switches the account you are logged in to`)} + activeOpacity={0.8}> {contents} ) @@ -162,12 +177,14 @@ export function SettingsScreen({}: Props) { const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() const {openModal} = useModalControls() - const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const {accounts, currentAccount} = useSession() const {mutate: clearPreferences} = useClearPreferencesMutation() const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() const exportCarControl = useDialogControl() const birthdayControl = useDialogControl() + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() + const isSwitchingAccounts = !!pendingDid // const primaryBg = useCustomPalette({ // light: {backgroundColor: colors.blue0}, @@ -238,7 +255,7 @@ export function SettingsScreen({}: Props) { const onPressBuildInfo = React.useCallback(() => { setStringAsync( - `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, + `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`, ) Toast.show(_(msg`Copied build version to clipboard`)) }, [_]) @@ -275,10 +292,6 @@ export function SettingsScreen({}: Props) { navigation.navigate('AccessibilitySettings') }, [navigation]) - const onPressStatusPage = React.useCallback(() => { - Linking.openURL(STATUS_PAGE_URL) - }, []) - const onPressBirthday = React.useCallback(() => { birthdayControl.open() }, [birthdayControl]) @@ -363,50 +376,53 @@ export function SettingsScreen({}: Props) { {!currentAccount.emailConfirmed && } + + + + Signed in as + + + + + + ) : null} - - - Signed in as - - - - {isSwitchingAccounts ? ( - - - - ) : ( - - )} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + + ))} - {accounts - .filter(a => a.did !== currentAccount?.did) - .map(account => ( - - ))} - - - - - - - Add account - - + + + + + + Add account + + + @@ -849,17 +865,9 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" onPress={onPressBuildInfo}> - Version {AppInfo.appVersion} - - - -   ·   - - - - Status page + + Version {appVersion} {bundleInfo} + @@ -881,6 +889,12 @@ export function SettingsScreen({}: Props) { href="https://bsky.social/about/support/privacy-policy" text={_(msg`Privacy Policy`)} /> + @@ -1026,7 +1040,6 @@ const styles = StyleSheet.create({ footer: { flex: 1, flexDirection: 'row', - alignItems: 'center', paddingLeft: 18, }, }) diff --git a/src/view/screens/Storybook/ListContained.tsx b/src/view/screens/Storybook/ListContained.tsx new file mode 100644 index 0000000000..b3ea091f40 --- /dev/null +++ b/src/view/screens/Storybook/ListContained.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import {FlatList, View} from 'react-native' + +import {ScrollProvider} from 'lib/ScrollContext' +import {List} from 'view/com/util/List' +import {Button, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +export function ListContained() { + const [animated, setAnimated] = React.useState(false) + const ref = React.useRef(null) + + const data = React.useMemo(() => { + return Array.from({length: 100}, (_, i) => ({ + id: i, + text: `Message ${i}`, + })) + }, []) + + return ( + <> + + { + 'worklet' + console.log( + JSON.stringify({ + contentOffset: e.contentOffset, + layoutMeasurement: e.layoutMeasurement, + contentSize: e.contentSize, + }), + ) + }}> + { + return ( + + {item.item.text} + + ) + }} + keyExtractor={item => item.id.toString()} + containWeb={true} + style={{flex: 1}} + onStartReached={() => { + console.log('Start Reached') + }} + onEndReached={() => { + console.log('End Reached (threshold of 2)') + }} + onEndReachedThreshold={2} + ref={ref} + disableVirtualization={true} + /> + + + + + setAnimated(prev => !prev)}> + + Animated Scrolling + + + + + + + + + + ) +} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 35a6666016..282b3ff5c7 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -1,8 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {ScrollView, View} from 'react-native' import {useSetThemePrefs} from '#/state/shell' -import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {isWeb} from 'platform/detection' +import {CenteredView} from '#/view/com/util/Views' +import {ListContained} from 'view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Breakpoints} from './Breakpoints' @@ -18,77 +20,111 @@ import {Theming} from './Theming' import {Typography} from './Typography' export function Storybook() { + if (isWeb) return + + return ( + + + + ) +} + +function StorybookInner() { const t = useTheme() const {setColorMode, setDarkTheme} = useSetThemePrefs() + const [showContainedList, setShowContainedList] = React.useState(false) return ( - - - - - - + + + {!showContainedList ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> - - - - - - - - - - - - - - - - - - - - - - - - - - + + + )} + + ) } diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 8145fa4087..d8e604ec31 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -18,6 +18,7 @@ import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' import {emitSoftReset} from '#/state/events' +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' import {SessionAccount, useSession} from '#/state/session' @@ -117,6 +118,7 @@ let DrawerContent = ({}: {}): React.ReactNode => { const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {hasSession, currentAccount} = useSession() + const kawaii = useKawaiiMode() // events // = @@ -262,6 +264,17 @@ let DrawerContent = ({}: {}): React.ReactNode => { href="https://bsky.social/about/support/privacy-policy" text={_(msg`Privacy Policy`)} /> + {kawaii && ( + + Logo by{' '} + + + )} diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index c1f4987248..f0cd4f59ae 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -1,22 +1,26 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {DesktopSearch} from './Search' -import {DesktopFeeds} from './Feeds' -import {Text} from 'view/com/util/text/Text' -import {TextLink} from 'view/com/util/Link' -import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' -import {s} from 'lib/styles' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useSession} from '#/state/session' +import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {s} from 'lib/styles' +import {TextLink} from 'view/com/util/Link' +import {Text} from 'view/com/util/text/Text' +import {DesktopFeeds} from './Feeds' +import {DesktopSearch} from './Search' export function DesktopRightNav({routeName}: {routeName: string}) { const pal = usePalette('default') const {_} = useLingui() const {hasSession, currentAccount} = useSession() + const kawaii = useKawaiiMode() + const {isTablet} = useWebMediaQueries() if (isTablet) { return null @@ -90,6 +94,17 @@ export function DesktopRightNav({routeName}: {routeName: string}) { text={_(msg`Help`)} /> + {kawaii && ( + + Logo by{' '} + + + )} diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 683d4421a7..3829a6c0b2 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -21,8 +21,8 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {s} from '#/lib/styles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' -import {useModerationOpts} from '#/state/queries/preferences' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon2} from 'lib/icons' import {NavigationProp} from 'lib/routes/types' diff --git a/tsconfig.json b/tsconfig.json index 13e9741bc2..1c5e27eecd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,6 @@ "view/*": ["./src/view/*"], "crypto": ["./src/platform/crypto.ts"] } - } + }, + "exclude": ["bskyweb", "bskyembed", "web-build"] } diff --git a/yarn.lock b/yarn.lock index 1de8505f2a..d56738c6f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,6 +34,18 @@ jsonpointer "^5.0.0" leven "^3.1.0" +"@atproto-labs/api@^0.12.8-clipclops.0": + version "0.12.8-clipclops.0" + resolved "https://registry.yarnpkg.com/@atproto-labs/api/-/api-0.12.8-clipclops.0.tgz#1c5d41d3396e439a0b645f7e1ccf500cc4b42580" + integrity sha512-YYDtWWk6BR+aRBVja/1v+gceNK81lkmF5bi6O4pTmJhFt/321XATx/ql8uTWta4VnVThoFeNPG6nLr7hs8b9cA== + dependencies: + "@atproto/common-web" "^0.3.0" + "@atproto/lexicon" "^0.4.0" + "@atproto/syntax" "^0.3.0" + "@atproto/xrpc" "^0.5.0" + multiformats "^9.9.0" + tlds "^1.234.0" + "@atproto/api@^0.12.3": version "0.12.3" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17" @@ -2958,15 +2970,15 @@ mv "~2" safe-json-stringify "~1" -"@expo/cli@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.17.8.tgz#4abe0d8c604b73a6e1d0a10f34e993cbf1cbad42" - integrity sha512-yfkoghCltbGPDbRI71Qu3puInjXx4wO82+uhW82qbWLvosfIN7ep5Gr0Lq54liJpvlUG6M0IXM1GiGqcCyP12w== +"@expo/cli@0.17.10": + version "0.17.10" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.17.10.tgz#7dd5e2b4a01f5d29698c431729a19878fbd806f5" + integrity sha512-Jw2wY+lsavP9GRqwwLqF/SvB7w2GZ4sWBMcBKTZ8F0lWjwmLGAUt4WYquf20agdmnY/oZUHvWNkrz/t3SflhnA== dependencies: "@babel/runtime" "^7.20.0" "@expo/code-signing-certificates" "0.0.5" "@expo/config" "~8.5.0" - "@expo/config-plugins" "~7.8.0" + "@expo/config-plugins" "~7.9.0" "@expo/devcert" "^1.0.0" "@expo/env" "~0.2.2" "@expo/image-utils" "^0.4.0" @@ -2975,7 +2987,7 @@ "@expo/osascript" "^2.0.31" "@expo/package-manager" "^1.1.1" "@expo/plist" "^0.1.0" - "@expo/prebuild-config" "6.7.4" + "@expo/prebuild-config" "6.8.1" "@expo/rudder-sdk-node" "1.1.1" "@expo/spawn-async" "1.5.0" "@expo/xcpretty" "^4.3.0" @@ -3070,10 +3082,10 @@ xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-plugins@7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.4.tgz#533b5d536c1dc8b5544d64878b51bda28f2e1a1f" - integrity sha512-hv03HYxb/5kX8Gxv/BTI8TLc9L06WzqAfHRRXdbar4zkLcP2oTzvsLEF4/L/TIpD3rsnYa0KU42d0gWRxzPCJg== +"@expo/config-plugins@7.9.1", "@expo/config-plugins@~7.9.0": + version "7.9.1" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.9.1.tgz#fe4f7e4f9d4e87f2dcf2344ffdc59eb466dd5d2e" + integrity sha512-ICt6Jed1J0tPYMQrJ8K5Qusgih2I6pZ2PU4VSvxsN3T4n97L13XpYV1vyq1Uc/HMl3UhOwldipmgpEbCfeDqsQ== dependencies: "@expo/config-types" "^50.0.0-alpha.1" "@expo/fingerprint" "^0.6.0" @@ -3114,29 +3126,6 @@ xcode "^3.0.1" xml2js "0.4.23" -"@expo/config-plugins@~7.8.2": - version "7.8.2" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.2.tgz#c00ce93c4d6c2cb9e345ed9cd56ceeea05ab8ddb" - integrity sha512-XM2eXA5EvcpmXFCui48+bVy8GTskYSjPf2yC+LliYv8PDcedu7+pdgmbnvH4eZCyHfTMO8/UiF+w8e5WgOEj5A== - dependencies: - "@expo/config-types" "^50.0.0-alpha.1" - "@expo/fingerprint" "^0.6.0" - "@expo/json-file" "~8.3.0" - "@expo/plist" "^0.1.0" - "@expo/sdk-runtime-versions" "^1.0.0" - "@react-native/normalize-color" "^2.0.0" - chalk "^4.1.2" - debug "^4.3.1" - find-up "~5.0.0" - getenv "^1.0.0" - glob "7.1.6" - resolve-from "^5.0.0" - semver "^7.5.3" - slash "^3.0.0" - slugify "^1.6.6" - xcode "^3.0.1" - xml2js "0.6.0" - "@expo/config-types@^47.0.0": version "47.0.0" resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-47.0.0.tgz#99eeabe0bba7a776e0f252b78beb0c574692c38d" @@ -3147,13 +3136,13 @@ resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-50.0.0.tgz#b534d3ec997ec60f8af24f6ad56244c8afc71a0b" integrity sha512-0kkhIwXRT6EdFDwn+zTg9R2MZIAEYGn1MVkyRohAd+C9cXOb5RA8WLQi7vuxKF9m1SMtNAUrf0pO+ENK0+/KSw== -"@expo/config@8.5.4": - version "8.5.4" - resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.4.tgz#bb5eb06caa36e4e35dc8c7647fae63e147b830ca" - integrity sha512-ggOLJPHGzJSJHVBC1LzwXwR6qUn8Mw7hkc5zEKRIdhFRuIQ6s2FE4eOvP87LrNfDF7eZGa6tJQYsiHSmZKG+8Q== +"@expo/config@8.5.6": + version "8.5.6" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.6.tgz#e37ba437a1718ed4629e1dd130a7aace25312b89" + integrity sha512-wF5awSg6MNn1cb1lIgjnhOn5ov2TEUTnkAVCsOl0QqDwcP+YIerteSFwjn9V52UZvg58L+LKxpCuGbw5IHavbg== dependencies: "@babel/code-frame" "~7.10.4" - "@expo/config-plugins" "~7.8.2" + "@expo/config-plugins" "~7.9.0" "@expo/config-types" "^50.0.0" "@expo/json-file" "^8.2.37" getenv "^1.0.0" @@ -3318,10 +3307,10 @@ json5 "^2.2.2" write-file-atomic "^2.3.0" -"@expo/metro-config@0.17.6": - version "0.17.6" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.17.6.tgz#f1f4ef056aa357c1dba3841de465f5d319f17216" - integrity sha512-WaC1C+sLX/Wa7irwUigLhng3ckmXIEQefZczB8DfYmleV6uhfWWo2kz/HijFBpV7FKs2cW6u8J/aBQpFkxlcqg== +"@expo/metro-config@0.17.7": + version "0.17.7" + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.17.7.tgz#c877a9558f3b97447cc9cf382971403834d84b46" + integrity sha512-3vAdinAjMeRwdhGWWLX6PziZdAPvnyJ6KVYqnJErHHqH0cA6dgAENT3Vq6PEM1H2HgczKr2d5yG9AMgwy848ow== dependencies: "@babel/core" "^7.20.0" "@babel/generator" "^7.20.5" @@ -3445,6 +3434,22 @@ semver "7.5.3" xml2js "0.6.0" +"@expo/prebuild-config@6.8.1": + version "6.8.1" + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-6.8.1.tgz#5d562b1d6b2e5e4727a3c61acf1a4ed6117b94d8" + integrity sha512-ptK9e0dcj1eYlAWV+fG+QkuAWcLAT1AmtEbj++tn7ZjEj8+LkXRM73LCOEGaF0Er8i8ZWNnaVsgGW4vjgP5ZsA== + dependencies: + "@expo/config" "~8.5.0" + "@expo/config-plugins" "~7.9.0" + "@expo/config-types" "^50.0.0-alpha.1" + "@expo/image-utils" "^0.4.0" + "@expo/json-file" "^8.2.37" + debug "^4.3.1" + fs-extra "^9.0.0" + resolve-from "^5.0.0" + semver "7.5.3" + xml2js "0.6.0" + "@expo/rudder-sdk-node@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz#6aa575f346833eb6290282118766d4919c808c6a" @@ -8965,10 +8970,10 @@ babel-preset-expo@^10.0.0: babel-plugin-react-native-web "~0.18.10" react-refresh "0.14.0" -babel-preset-expo@~10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-10.0.1.tgz#a0e7ad0119f46e58cb3f0738c3ca0c6e97b69c11" - integrity sha512-uWIGmLfbP3dS5+8nesxaW6mQs41d4iP7X82ZwRdisB/wAhKQmuJM9Y1jQe4006uNYkw6Phf2TT03ykLVro7KuQ== +babel-preset-expo@~10.0.2: + version "10.0.2" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-10.0.2.tgz#5aae992b8c85dce6cf98334c9991d3052c567950" + integrity sha512-hg06qdSTK7MjKmFXSiq6cFoIbI3n3uT8a3NI2EZoISWhu+tedCj4DQduwi+3adFuRuYvAwECI0IYn/5iGh5zWQ== dependencies: "@babel/plugin-proposal-decorators" "^7.12.9" "@babel/plugin-transform-export-namespace-from" "^7.22.11" @@ -11876,7 +11881,7 @@ expo-eas-client@~0.11.0: resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.11.0.tgz#0f25aa497849cade7ebef55c0631093a87e58b07" integrity sha512-99W0MUGe3U4/MY1E9UeJ4uKNI39mN8/sOGA0Le8XC47MTbwbLoVegHR3C5y2fXLwLn7EpfNxAn5nlxYjY3gD2A== -expo-file-system@^16.0.9: +expo-file-system@^16.0.9, expo-file-system@~16.0.9: version "16.0.9" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.9.tgz#cbd6c4b228b60a6b6c71fd1b91fe57299fb24da7" integrity sha512-3gRPvKVv7/Y7AdD9eHMIdfg5YbUn2zbwKofjsloTI5sEC57SLUFJtbLvUCz9Pk63DaSQ7WIE1JM0EASyvuPbuw== @@ -11886,11 +11891,6 @@ expo-file-system@~16.0.0: resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.1.tgz#326b7c2f6e53e1a0eaafc9769578aafb3f9c9f43" integrity sha512-/U6ufN2wRPgg4m2a9sqbL3dThqQsysT022qulEXWnUTmNaqnzYSk9ihjDWqoqjXLi9slQLsyok5t6CNzhM7HPw== -expo-file-system@~16.0.8: - version "16.0.8" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.8.tgz#13c79a8e06e42a8e76e9297df6920597a011d989" - integrity sha512-yDbVT0TUKd7ewQjaY5THum2VRFx2n/biskGhkUmLh3ai21xjIVtaeIzHXyv9ir537eVgt4ReqDNWi7jcXjdUcA== - expo-font@~11.10.3: version "11.10.3" resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-11.10.3.tgz#a3115ebda8e09bd7cb8052619a4bbe606f0c17f4" @@ -11984,10 +11984,10 @@ expo-modules-autolinking@1.10.3: find-up "^5.0.0" fs-extra "^9.1.0" -expo-modules-core@1.11.12: - version "1.11.12" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.11.12.tgz#d5c7b3ed7ab57d4fb6885a0d8e10287dcf1ffe5f" - integrity sha512-/e8g4kis0pFLer7C0PLyx98AfmztIM6gU9jLkYnB1pU9JAfQf904XEi3bmszO7uoteBQwSL6FLp1m3TePKhDaA== +expo-modules-core@1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.11.13.tgz#a8e63ad844e966dce78dea40b50839af6c3bc518" + integrity sha512-2H5qrGUvmLzmJNPDOnovH1Pfk5H/S/V0BifBmOQyDc9aUh9LaDwkqnChZGIXv8ZHDW8JRlUW0QqyWxTggkbw1A== dependencies: invariant "^2.2.4" @@ -12090,24 +12090,24 @@ expo-web-browser@~12.8.2: compare-urls "^2.0.0" url "^0.11.0" -expo@^50.0.8: - version "50.0.14" - resolved "https://registry.yarnpkg.com/expo/-/expo-50.0.14.tgz#ddcae86aa0ba8d1be3da9ad1bdda23fa539dc97d" - integrity sha512-yLPdxCMVAbmeEIpzzyAuJ79wvr6ToDDtQmuLDMAgWtjqP8x3CGddXxUe07PpKEQgzwJabdHvCLP5Bv94wMFIjQ== +expo@^50.0.17: + version "50.0.17" + resolved "https://registry.yarnpkg.com/expo/-/expo-50.0.17.tgz#ab0998d7e7c18e8d12efd9091f9688978b0e89ed" + integrity sha512-eD8Nh10BgVwecU7EVyogx7X314ajxVpJdFwkXhi341AD61S2WPX31NMHW82XGXas6dbDjdbgtaOMo5H/vylB7Q== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.17.8" - "@expo/config" "8.5.4" - "@expo/config-plugins" "7.8.4" - "@expo/metro-config" "0.17.6" + "@expo/cli" "0.17.10" + "@expo/config" "8.5.6" + "@expo/config-plugins" "7.9.1" + "@expo/metro-config" "0.17.7" "@expo/vector-icons" "^14.0.0" - babel-preset-expo "~10.0.1" + babel-preset-expo "~10.0.2" expo-asset "~9.0.2" - expo-file-system "~16.0.8" + expo-file-system "~16.0.9" expo-font "~11.10.3" expo-keep-awake "~12.8.2" expo-modules-autolinking "1.10.3" - expo-modules-core "1.11.12" + expo-modules-core "1.11.13" fbemitter "^3.0.0" whatwg-url-without-unicode "8.0.0-3" @@ -18707,6 +18707,11 @@ react-native-ios-context-menu@^1.15.3: dependencies: "@dominicstop/ts-event-emitter" "^1.1.0" +react-native-keyboard-controller@^1.11.7: + version "1.11.7" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.7.tgz#85640374e4c3627c3b667256a1d308698ff80393" + integrity sha512-K2zlqVyWX4QO7r+dHMQgZT41G2dSEWtDYgBdht1WVyTaMQmwTMalZcHCWBVOnzyGaJq/hMKhF1kSPqJP1xqSFA== + react-native-pager-view@6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775" @@ -18976,6 +18981,15 @@ react-test-renderer@18.2.0: react-shallow-renderer "^16.15.0" scheduler "^0.23.0" +react-textarea-autosize@^8.5.3: + version "8.5.3" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409" + integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ== + dependencies: + "@babel/runtime" "^7.20.13" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" @@ -21348,6 +21362,16 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" +use-composed-ref@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" + integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== + +use-isomorphic-layout-effect@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-latest-callback@^0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6" @@ -21358,6 +21382,13 @@ use-latest-callback@^0.1.9: resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.9.tgz#10191dc54257e65a8e52322127643a8940271e2a" integrity sha512-CL/29uS74AwreI/f2oz2hLTW7ZqVeV5+gxFeGudzQrgkCytrHw33G4KbnQOrRlAEzzAFXi7dDLMC9zhWcVpzmw== +use-latest@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2" + integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + use-sidecar@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"