diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index c487c2ab8a..ec9e0d320e 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -120,6 +120,32 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + - name: 🏗️ Build Production APK + if: ${{ inputs.profile == 'production' }} + run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive + + - name: 🚀 Upload Production APK Artifact + id: upload-artifact-production-apk + if: ${{ inputs.profile == 'production' }} + uses: actions/upload-artifact@v4 + with: + retention-days: 30 + compression-level: 6 + name: build-${{ steps.timestamp.outputs.time }}.apk + path: build.apk + + - name: 🔔 Notify Slack of Production APK Build + if: ${{ inputs.profile == 'production' }} + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Android production APK build is ready for download. This is a production build, and you should add it to the GitHub release! Download the artifact here: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + - name: ⬇️ Restore Cache id: get-base-commit uses: actions/cache@v4 diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index cf21d8dd25..78478a26d4 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -480,6 +480,26 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://media.tenor.com/someID/someName.gif', 'https://media.tenor.com/someID', 'https://media.tenor.com', + + 'https://www.flickr.com/photos/username/albums/72177720308493661', + 'https://flickr.com/photos/username/albums/72177720308493661', + 'https://flickr.com/photos/username/albums/72177720308493661/', + 'https://flickr.com/photos/username/albums/72177720308493661//', + 'https://flic.kr/s/aHBqjAES3i', + + 'https://flickr.com/foetoes/username/albums/3903', + 'https://flickr.com/albums/3903', + 'https://flic.kr/s/OolI', + 'https://flic.kr/t/aHBqjAES3i', + + 'https://www.flickr.com/groups/898944@N23/pool', + 'https://flickr.com/groups/898944@N23/pool', + 'https://flickr.com/groups/898944@N23/pool/', + 'https://flickr.com/groups/898944@N23/pool//', + 'https://flic.kr/go/8WJtR', + + 'https://www.flickr.com/groups/898944@N23/', + 'https://www.flickr.com/groups', ] const outputs = [ @@ -777,6 +797,66 @@ describe('parseEmbedPlayerFromUrl', () => { undefined, undefined, undefined, + + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/photosets/72177720308493661', + }, + + undefined, + undefined, + undefined, + undefined, + + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + { + type: 'flickr_album', + source: 'flickr', + playerUri: 'https://embedr.flickr.com/groups/898944@N23', + }, + + undefined, + undefined, ] it('correctly grabs the correct id from uri', () => { diff --git a/assets/icons/image_stroke2_corner0_rounded.svg b/assets/icons/image_stroke2_corner0_rounded.svg index 389020b0d1..3363e186db 100644 --- a/assets/icons/image_stroke2_corner0_rounded.svg +++ b/assets/icons/image_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/eas.json b/eas.json index ed647dbb9c..a705c40027 100644 --- a/eas.json +++ b/eas.json @@ -46,6 +46,20 @@ "EXPO_PUBLIC_ENV": "production" } }, + "production-apk": { + "extends": "base", + "distribution": "internal", + "ios": { + "autoIncrement": false + }, + "android": { + "autoIncrement": false + }, + "channel": "production", + "env": { + "EXPO_PUBLIC_ENV": "production" + } + }, "testflight": { "extends": "base", "ios": { diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx new file mode 100644 index 0000000000..091a23e51c --- /dev/null +++ b/src/components/dialogs/GifSelect.ios.tsx @@ -0,0 +1,255 @@ +import React, { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import {Modal, ScrollView, TextInput, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' +import { + Gif, + useFeaturedGifsQuery, + useGifSearchQuery, +} from '#/state/queries/tenor' +import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {FlatList_INTERNAL} from '#/view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {Button, ButtonText} from '../Button' +import {Handle} from '../Dialog' +import {useThrottledValue} from '../hooks/useThrottledValue' +import {ListFooter, ListMaybePlaceholder} from '../Lists' +import {GifPreview} from './GifSelect.shared' + +export function GifSelectDialog({ + controlRef, + onClose, + onSelectGif: onSelectGifProp, +}: { + controlRef: React.RefObject<{open: () => void}> + onClose: () => void + onSelectGif: (gif: Gif) => void +}) { + const t = useTheme() + const [open, setOpen] = useState(false) + + useImperativeHandle(controlRef, () => ({ + open: () => setOpen(true), + })) + + const close = useCallback(() => { + setOpen(false) + onClose() + }, [onClose]) + + const onSelectGif = useCallback( + (gif: Gif) => { + onSelectGifProp(gif) + close() + }, + [onSelectGifProp, close], + ) + + const renderErrorBoundary = useCallback( + (error: any) => , + [close], + ) + + return ( + + + + + + + + + ) +} + +function GifList({ + onSelectGif, +}: { + close: () => void + onSelectGif: (gif: Gif) => void +}) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const textInputRef = useRef(null) + const listRef = useRef(null) + const [undeferredSearch, setSearch] = useState('') + const search = useThrottledValue(undeferredSearch, 500) + + const isSearching = search.length > 0 + + const trendingQuery = useFeaturedGifsQuery() + const searchQuery = useGifSearchQuery(search) + + const { + data, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + error, + isLoading, + isError, + refetch, + } = isSearching ? searchQuery : trendingQuery + + const flattenedData = useMemo(() => { + return data?.pages.flatMap(page => page.results) || [] + }, [data]) + + const renderItem = useCallback( + ({item}: {item: Gif}) => { + return + }, + [onSelectGif], + ) + + const onEndReached = React.useCallback(() => { + if (isFetchingNextPage || !hasNextPage || error) return + fetchNextPage() + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + + const hasData = flattenedData.length > 0 + + const onGoBack = useCallback(() => { + if (isSearching) { + // clear the input and reset the state + textInputRef.current?.clear() + setSearch('') + } else { + close() + } + }, [isSearching]) + + const listHeader = useMemo(() => { + return ( + + {/* cover top corners */} + + + + + { + setSearch(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + returnKeyType="search" + clearButtonMode="while-editing" + inputRef={textInputRef} + maxLength={50} + /> + + + ) + }, [t.atoms.bg, _]) + + return ( + + {listHeader} + {!hasData && ( + + )} + + } + stickyHeaderIndices={[0]} + onEndReached={onEndReached} + onEndReachedThreshold={4} + keyExtractor={(item: Gif) => item.id} + keyboardDismissMode="on-drag" + ListFooterComponent={ + hasData ? ( + + ) : null + } + /> + ) +} + +function ModalError({details, close}: {details?: string; close: () => void}) { + const {_} = useLingui() + + return ( + + + + + ) +} diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx new file mode 100644 index 0000000000..90b2abaa83 --- /dev/null +++ b/src/components/dialogs/GifSelect.shared.tsx @@ -0,0 +1,53 @@ +import React, {useCallback} from 'react' +import {Image} from 'expo-image' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logEvent} from '#/lib/statsig/statsig' +import {Gif} from '#/state/queries/tenor' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button} from '../Button' + +export function GifPreview({ + gif, + onSelectGif, +}: { + gif: Gif + onSelectGif: (gif: Gif) => void +}) { + const {gtTablet} = useBreakpoints() + const {_} = useLingui() + const t = useTheme() + + const onPress = useCallback(() => { + logEvent('composer:gif:select', {}) + onSelectGif(gif) + }, [onSelectGif, gif]) + + return ( + + ) +} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 4a3ce42aa9..a64edcd6f0 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -1,11 +1,15 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react' +import React, { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import {TextInput, View} from 'react-native' -import {Image} from 'expo-image' import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {isWeb} from '#/platform/detection' import { @@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {Button, ButtonIcon, ButtonText} from '../Button' import {ListFooter, ListMaybePlaceholder} from '../Lists' +import {GifPreview} from './GifSelect.shared' export function GifSelectDialog({ - control, + controlRef, onClose, onSelectGif: onSelectGifProp, }: { - control: Dialog.DialogControlProps + controlRef: React.RefObject<{open: () => void}> onClose: () => void onSelectGif: (gif: Gif) => void }) { + const control = Dialog.useDialogControl() + + useImperativeHandle(controlRef, () => ({ + open: () => control.open(), + })) + const onSelectGif = useCallback( (gif: Gif) => { control.close(() => onSelectGifProp(gif)) @@ -233,50 +244,6 @@ function GifList({ ) } -function GifPreview({ - gif, - onSelectGif, -}: { - gif: Gif - onSelectGif: (gif: Gif) => void -}) { - const {gtTablet} = useBreakpoints() - const {_} = useLingui() - const t = useTheme() - - const onPress = useCallback(() => { - logEvent('composer:gif:select', {}) - onSelectGif(gif) - }, [onSelectGif, gif]) - - return ( - - ) -} - function DialogError({details}: {details?: string}) { const {_} = useLingui() const control = Dialog.useDialogContext() diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 9deb0c1d91..aefd62b9ac 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,9 +2,8 @@ import React from 'react' import {View} from 'react-native' import {AppBskyEmbedRecord} from '@atproto/api' -import {isNative} from '#/platform/detection' import {PostEmbeds} from '#/view/com/util/post-embeds' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, native, useTheme} from '#/alf' let MessageItemEmbed = ({ embed, @@ -14,9 +13,8 @@ let MessageItemEmbed = ({ const t = useTheme() return ( - - + + ) } diff --git a/src/components/icons/Image.tsx b/src/components/icons/Image.tsx index 03702a0f46..eac296ad42 100644 --- a/src/components/icons/Image.tsx +++ b/src/components/icons/Image.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', }) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 48651b3d96..753734edd8 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -13,7 +13,7 @@ export type LogEvents = { withPassword: boolean } 'account:loggedOut': { - logContext: 'SwitchAccount' | 'Settings' | 'Deactivated' + logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' } 'notifications:openApp': {} 'notifications:request': { diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 54649f1431..30ced14921 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -23,6 +23,7 @@ export const embedPlayerSources = [ 'vimeo', 'giphy', 'tenor', + 'flickr', ] as const export type EmbedPlayerSource = (typeof embedPlayerSources)[number] @@ -42,6 +43,7 @@ export type EmbedPlayerType = | 'vimeo_video' | 'giphy_gif' | 'tenor_gif' + | 'flickr_album' export const externalEmbedLabels: Record = { youtube: 'YouTube', @@ -53,6 +55,7 @@ export const externalEmbedLabels: Record = { spotify: 'Spotify', appleMusic: 'Apple Music', soundcloud: 'SoundCloud', + flickr: 'Flickr', } export interface EmbedPlayerParams { @@ -375,6 +378,79 @@ export function parseEmbedPlayerFromUrl( } } } + + // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path + if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { + let i = urlp.pathname.length - 1 + while (i > 0 && urlp.pathname.charAt(i) === '/') { + --i + } + + const path_components = urlp.pathname.slice(1, i + 1).split('/') + if (path_components.length === 4) { + // discard username - it's not relevant + const [photos, _, albums, id] = path_components + if (photos === 'photos' && albums === 'albums') { + // this at least has the shape of a valid photo-album URL! + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/photosets/${id}`, + } + } + } + + if (path_components.length === 3) { + const [groups, id, pool] = path_components + if (groups === 'groups' && pool === 'pool') { + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/groups/${id}`, + } + } + } + // not an album or a group pool, don't know what to do with this! + return undefined + } + + // link shortened flickr path + if (urlp.hostname === 'flic.kr') { + const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ' + let [_, type, idBase58Enc] = urlp.pathname.split('/') + let id = 0n + for (const char of idBase58Enc) { + const nextIdx = b58alph.indexOf(char) + if (nextIdx >= 0) { + id = id * 58n + BigInt(nextIdx) + } else { + // not b58 encoded, ergo not a valid link to embed + return undefined + } + } + + switch (type) { + case 'go': + const formattedGroupId = `${id}` + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice( + 0, + -2, + )}@N${formattedGroupId.slice(-2)}`, + } + case 's': + return { + type: 'flickr_album', + source: 'flickr', + playerUri: `https://embedr.flickr.com/photosets/${id}`, + } + default: + // we don't know what this is so we can't embed it + return undefined + } + } } export function getPlayerAspect({ diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 2a20373a42..4c75f47add 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -3,6 +3,7 @@ import psl from 'psl' import TLDs from 'tlds' import {BSKY_SERVICE} from 'lib/constants' +import {isInvalidHandle} from 'lib/strings/handles' export const BSKY_APP_HOST = 'https://bsky.app' const BSKY_TRUSTED_HOSTS = [ @@ -83,6 +84,10 @@ export function toShareUrl(url: string): string { return url } +export function toBskyAppUrl(url: string): string { + return new URL(url, BSKY_APP_HOST).toString() +} + export function isBskyAppUrl(url: string): boolean { return url.startsWith('https://bsky.app/') } @@ -183,6 +188,22 @@ export function feedUriToHref(url: string): string { } } +export function postUriToRelativePath( + uri: string, + options?: {handle?: string}, +): string | undefined { + try { + const {hostname, rkey} = new AtUri(uri) + const handleOrDid = + options?.handle && !isInvalidHandle(options.handle) + ? options.handle + : hostname + return `/profile/${handleOrDid}/post/${rkey}` + } catch { + return undefined + } +} + /** * Checks if the label in the post text matches the host of the link facet. * diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index c9e9f95254..faee517cb8 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -1,19 +1,22 @@ import React from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {msg, plural, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' -import {logger} from '#/logger' +import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {isWeb} from '#/platform/detection' -import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session' -import {useOnboardingDispatch} from '#/state/shell' +import {type SessionAccount, useSession, useSessionApi} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {ScrollView} from '#/view/com/util/Views' import {Logo} from '#/view/icons/Logo' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Loader} from '#/components/Loader' -import {P, Text} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import {AccountList} from '#/components/AccountList' +import {Button, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {Text} from '#/components/Typography' const COL_WIDTH = 400 @@ -21,199 +24,151 @@ export function Deactivated() { const {_} = useLingui() const t = useTheme() const insets = useSafeAreaInsets() - const {gtMobile} = useBreakpoints() - const onboardingDispatch = useOnboardingDispatch() + const {currentAccount, accounts} = useSession() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() + const {setShowLoggedOut} = useLoggedOutViewControls() + const hasOtherAccounts = accounts.length > 1 + const setMinimalShellMode = useSetMinimalShellMode() const {logout} = useSessionApi() - const agent = useAgent() - const [isProcessing, setProcessing] = React.useState(false) - const [estimatedTime, setEstimatedTime] = React.useState( - undefined, - ) - const [placeInQueue, setPlaceInQueue] = React.useState( - undefined, + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(true) + }, [setMinimalShellMode]), ) - const checkStatus = React.useCallback(async () => { - setProcessing(true) - try { - const res = await agent.com.atproto.temp.checkSignupQueue() - if (res.data.activated) { - // ready to go, exchange the access token for a usable one and kick off onboarding - await agent.refreshSession() - if (!isSessionDeactivated(agent.session?.accessJwt)) { - onboardingDispatch({type: 'start'}) - } - } else { - // not ready, update UI - setEstimatedTime(msToString(res.data.estimatedTimeMs)) - if (typeof res.data.placeInQueue !== 'undefined') { - setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) - } + const onSelectAccount = React.useCallback( + (account: SessionAccount) => { + if (account.did !== currentAccount?.did) { + onPressSwitchAccount(account, 'SwitchAccount') } - } catch (e: any) { - logger.error('Failed to check signup queue', {err: e.toString()}) - } finally { - setProcessing(false) - } - }, [ - setProcessing, - setEstimatedTime, - setPlaceInQueue, - onboardingDispatch, - agent, - ]) - - React.useEffect(() => { - checkStatus() - const interval = setInterval(checkStatus, 60e3) - return () => clearInterval(interval) - }, [checkStatus]) - - const checkBtn = ( - + }, + [currentAccount, onPressSwitchAccount], ) + const onPressAddAccount = React.useCallback(() => { + setShowLoggedOut(true) + }, [setShowLoggedOut]) + + const onPressLogout = React.useCallback(() => { + if (isWeb) { + // We're switching accounts, which remounts the entire app. + // On mobile, this gets us Home, but on the web we also need reset the URL. + // We can't change the URL via a navigate() call because the navigator + // itself is about to unmount, and it calls pushState() too late. + // So we change the URL ourselves. The navigator will pick it up on remount. + history.pushState(null, '', '/') + } + logout('Deactivated') + }, [logout]) + return ( - + - - - - - - - - You're in line - -

- - There's been a rush of new users to Bluesky! We'll activate your - account as soon as we can. - -

- - - {typeof placeInQueue === 'number' && ( - - {placeInQueue} - - )} -

- {typeof placeInQueue === 'number' ? ( - left to go. - ) : ( - You are in line. - )}{' '} - {estimatedTime ? ( - - We estimate {estimatedTime} until your account is ready. - - ) : ( - - We will let you know when your account is ready. - - )} -

-
- - {isWeb && gtMobile && ( - - - {checkBtn} - - )} -
- - - -
- - {(!isWeb || !gtMobile) && ( - - {checkBtn} - + + + + + + + + + Welcome back! + + + + You previously deactivated @{currentAccount?.handle}. + + + + + You can reactivate your account to continue logging in. Your + profile and posts will be visible to other users. + + + + + + + + + + + + + + {hasOtherAccounts ? ( + <> + + Or, log into one of your other accounts. + + + + ) : ( + <> + + Or, continue with another account. + + + + )} + - )} + ) } - -function msToString(ms: number | undefined): string | undefined { - if (ms && ms > 0) { - const estimatedTimeMins = Math.ceil(ms / 60e3) - if (estimatedTimeMins > 59) { - const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) - if (estimatedTimeHrs > 6) { - // dont even bother - return undefined - } - // hours - return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, { - one: 'hour', - other: 'hours', - })}` - } - // minutes - return `${estimatedTimeMins} ${plural(estimatedTimeMins, { - one: 'minute', - other: 'minutes', - })}` - } - return undefined -} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index de77997f1d..f72515ac62 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -13,9 +13,13 @@ import { } from 'react-native-reanimated' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {AppBskyEmbedRecord, RichText} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, +} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {isConvoActive, useConvoActive} from '#/state/messages/convo' @@ -289,6 +293,39 @@ export function MessagesList({ cid: post.cid, }, } + + // look for the embed uri in the facets, so we can remove it from the text + const postLinkFacet = rt.facets?.find(facet => { + return facet.features.find(feature => { + if (AppBskyRichtextFacet.isLink(feature)) { + if (isBskyPostUrl(feature.uri)) { + const url = convertBskyAppUrlIfNeeded(feature.uri) + const [_0, _1, _2, rkey] = url.split('/').filter(Boolean) + + // this might have a handle instead of a DID + // so just compare the rkey - not particularly dangerous + return post.uri.endsWith(rkey) + } + } + return false + }) + }) + + if (postLinkFacet) { + const isAtStart = postLinkFacet.index.byteStart === 0 + const isAtEnd = + postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength + + // remove the post link from the text + if (isAtStart || isAtEnd) { + rt.delete( + postLinkFacet.index.byteStart, + postLinkFacet.index.byteEnd, + ) + } + + rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true}) + } } } catch (error) { logger.error('Failed to get post as quote for DM', {error}) diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index d5658249d7..9f8808366f 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react' import {GestureResponderEvent, View} from 'react-native' import { AppBskyActorDefs, + AppBskyEmbedRecord, ChatBskyConvoDefs, moderateProfile, ModerationOpts, @@ -9,6 +10,11 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import { + postUriToRelativePath, + toBskyAppUrl, + toShortUrl, +} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -95,21 +101,64 @@ function ChatListItemReady({ const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount - 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 + const {lastMessage, lastMessageSentAt} = React.useMemo(() => { + let lastMessage = _(msg`No messages yet`) + let lastMessageSentAt: string | null = null + + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did + + if (convo.lastMessage.text) { + if (isFromMe) { + lastMessage = _(msg`You: ${convo.lastMessage.text}`) + } else { + lastMessage = convo.lastMessage.text + } + } else if (convo.lastMessage.embed) { + const defaultEmbeddedContentMessage = _( + msg`(contains embedded content)`, + ) + + if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { + const embed = convo.lastMessage.embed + + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { + const record = embed.record + const path = postUriToRelativePath(record.uri, { + handle: record.author.handle, + }) + const href = path ? toBskyAppUrl(path) : undefined + const short = href + ? toShortUrl(href) + : defaultEmbeddedContentMessage + if (isFromMe) { + lastMessage = _(msg`You: ${short}`) + } else { + lastMessage = short + } + } + } else { + if (isFromMe) { + lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) + } else { + lastMessage = defaultEmbeddedContentMessage + } + } + } + + lastMessageSentAt = convo.lastMessage.sentAt } - lastMessageSentAt = convo.lastMessage.sentAt - } - if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) - } + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { + lastMessage = isDeletedAccount + ? _(msg`Conversation deleted`) + : _(msg`Message deleted`) + } + + return { + lastMessage, + lastMessageSentAt, + } + }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) const [showActions, setShowActions] = useState(false) diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx new file mode 100644 index 0000000000..4330ffcaa2 --- /dev/null +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {DialogOuterProps} from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' + +export function DeactivateAccountDialog({ + control, +}: { + control: DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + {_(msg`Deactivate account`)} + + + Your profile, posts, feeds, and lists will no longer be visible to + other Bluesky users. You can reactivate your account at any time by + logging in. + + + + + + + + + There is no time limit for account deactivation, come back any + time. + + + + + If you're trying to change your handle or email, do so before you + deactivate. + + + + + + + + {}} + color="negative" + /> + + + + ) +} diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx new file mode 100644 index 0000000000..4e4fedcfae --- /dev/null +++ b/src/screens/SignupQueued.tsx @@ -0,0 +1,219 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {isSignupQueued, useAgent, useSessionApi} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {ScrollView} from '#/view/com/util/Views' +import {Logo} from '#/view/icons/Logo' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' +import {P, Text} from '#/components/Typography' + +const COL_WIDTH = 400 + +export function SignupQueued() { + const {_} = useLingui() + const t = useTheme() + const insets = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const onboardingDispatch = useOnboardingDispatch() + const {logout} = useSessionApi() + const agent = useAgent() + + const [isProcessing, setProcessing] = React.useState(false) + const [estimatedTime, setEstimatedTime] = React.useState( + undefined, + ) + const [placeInQueue, setPlaceInQueue] = React.useState( + undefined, + ) + + const checkStatus = React.useCallback(async () => { + setProcessing(true) + try { + const res = await agent.com.atproto.temp.checkSignupQueue() + if (res.data.activated) { + // ready to go, exchange the access token for a usable one and kick off onboarding + await agent.refreshSession() + if (!isSignupQueued(agent.session?.accessJwt)) { + onboardingDispatch({type: 'start'}) + } + } else { + // not ready, update UI + setEstimatedTime(msToString(res.data.estimatedTimeMs)) + if (typeof res.data.placeInQueue !== 'undefined') { + setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) + } + } + } catch (e: any) { + logger.error('Failed to check signup queue', {err: e.toString()}) + } finally { + setProcessing(false) + } + }, [ + setProcessing, + setEstimatedTime, + setPlaceInQueue, + onboardingDispatch, + agent, + ]) + + React.useEffect(() => { + checkStatus() + const interval = setInterval(checkStatus, 60e3) + return () => clearInterval(interval) + }, [checkStatus]) + + const checkBtn = ( + + ) + + return ( + + + + + + + + + + You're in line + +

+ + There's been a rush of new users to Bluesky! We'll activate your + account as soon as we can. + +

+ + + {typeof placeInQueue === 'number' && ( + + {placeInQueue} + + )} +

+ {typeof placeInQueue === 'number' ? ( + left to go. + ) : ( + You are in line. + )}{' '} + {estimatedTime ? ( + + We estimate {estimatedTime} until your account is ready. + + ) : ( + + We will let you know when your account is ready. + + )} +

+
+ + {isWeb && gtMobile && ( + + + {checkBtn} + + )} +
+ + + +
+ + {(!isWeb || !gtMobile) && ( + + + {checkBtn} + + + + )} +
+ ) +} + +function msToString(ms: number | undefined): string | undefined { + if (ms && ms > 0) { + const estimatedTimeMins = Math.ceil(ms / 60e3) + if (estimatedTimeMins > 59) { + const estimatedTimeHrs = Math.round(estimatedTimeMins / 60) + if (estimatedTimeHrs > 6) { + // dont even bother + return undefined + } + // hours + return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, { + one: 'hour', + other: 'hours', + })}` + } + // minutes + return `${estimatedTimeMins} ${plural(estimatedTimeMins, { + one: 'minute', + other: 'minutes', + })}` + } + return undefined +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 77a79b78e4..7d579d55de 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -17,7 +17,10 @@ const accountSchema = z.object({ emailAuthFactor: z.boolean().optional(), refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire - deactivated: z.boolean().optional(), + signupQueued: z.boolean().optional(), + status: z + .enum(['active', 'takendown', 'suspended', 'deactivated']) + .optional(), pdsUrl: z.string().optional(), }) export type PersistedAccount = z.infer @@ -65,6 +68,7 @@ export const schema = z.object({ spotify: z.enum(externalEmbedOptions).optional(), appleMusic: z.enum(externalEmbedOptions).optional(), soundcloud: z.enum(externalEmbedOptions).optional(), + flickr: z.enum(externalEmbedOptions).optional(), }) .optional(), mutedThreads: z.array(z.string()), // should move to server diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 40be2ce8ee..d9f019af38 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -17,7 +17,7 @@ */ import {useEffect, useRef} from 'react' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import { InfiniteData, QueryClient, @@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads' import {useAgent} from '#/state/session' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' -import {embedViewRecordToPostView, getEmbeddedPost} from '../util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from '../util' import {FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' import {fetchPage} from './util' @@ -142,6 +146,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData>({ queryKey: [RQKEY_ROOT], }) @@ -149,14 +155,16 @@ export function* findAllPostsInQueryData( if (!queryData?.pages) { continue } + for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject?.uri === uri) { + if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { yield item.subject } + const quotedPost = getEmbeddedPost(item.subject?.embed) - if (quotedPost?.uri === uri) { - yield embedViewRecordToPostView(quotedPost) + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { + yield embedViewRecordToPostView(quotedPost!) } } } diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index ebcdff6866..4662493533 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -145,7 +145,7 @@ async function fetchSubjects( ): Promise> { const uris = new Set() for (const notif of groupedNotifs) { - if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) { + if (notif.subjectUri?.includes('app.bsky.feed.post')) { uris.add(notif.subjectUri) } } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 5c483483ac..2fb80de37d 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -35,7 +35,11 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' import {useModerationOpts} from '../preferences/moderation-opts' import {usePreferencesQuery} from './preferences' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' type ActorDid = string type AuthorFilter = @@ -448,6 +452,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData< InfiniteData >({ @@ -459,24 +465,38 @@ export function* findAllPostsInQueryData( } for (const page of queryData?.pages) { for (const item of page.feed) { - if (item.post.uri === uri) { + if (didOrHandleUriMatches(atUri, item.post)) { yield item.post } + const quotedPost = getEmbeddedPost(item.post.embed) - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPostView(quotedPost) } - if ( - AppBskyFeedDefs.isPostView(item.reply?.parent) && - item.reply?.parent?.uri === uri - ) { - yield item.reply.parent + + if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { + if (didOrHandleUriMatches(atUri, item.reply.parent)) { + yield item.reply.parent + } + + const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed) + if ( + parentQuotedPost && + didOrHandleUriMatches(atUri, parentQuotedPost) + ) { + yield embedViewRecordToPostView(parentQuotedPost) + } } - if ( - AppBskyFeedDefs.isPostView(item.reply?.root) && - item.reply?.root?.uri === uri - ) { - yield item.reply.root + + if (AppBskyFeedDefs.isPostView(item.reply?.root)) { + if (didOrHandleUriMatches(atUri, item.reply.root)) { + yield item.reply.root + } + + const rootQuotedPost = getEmbeddedPost(item.reply.root.embed) + if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) { + yield embedViewRecordToPostView(rootQuotedPost) + } } } } diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index b1bff1493f..f7d21a4270 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -4,6 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedGetPostThread, AppBskyFeedPost, + AtUri, ModerationDecision, ModerationOpts, } from '@atproto/api' @@ -24,7 +25,11 @@ import { findAllPostsInQueryData as findAllPostsInFeedQueryData, findAllProfilesInQueryData as findAllProfilesInFeedQueryData, } from './post-feed' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] @@ -91,14 +96,10 @@ export function usePostThreadQuery(uri: string | undefined) { }, enabled: !!uri, placeholderData: () => { - if (!uri) { - return undefined - } - { - const post = findPostInQueryData(queryClient, uri) - if (post) { - return post - } + if (!uri) return + const post = findPostInQueryData(queryClient, uri) + if (post) { + return post } return undefined }, @@ -271,6 +272,8 @@ export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { + const atUri = new AtUri(uri) + const queryDatas = queryClient.getQueriesData({ queryKey: [RQKEY_ROOT], }) @@ -279,7 +282,7 @@ export function* findAllPostsInQueryData( continue } for (const item of traverseThread(queryData)) { - if (item.uri === uri) { + if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) { const placeholder = threadNodeToPlaceholderThread(item) if (placeholder) { yield placeholder @@ -287,7 +290,7 @@ export function* findAllPostsInQueryData( } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPlaceholderThread(quotedPost) } } diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts index f71d642551..5c50ad2671 100644 --- a/src/state/queries/search-posts.ts +++ b/src/state/queries/search-posts.ts @@ -2,6 +2,7 @@ import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedSearchPosts, + AtUri, } from '@atproto/api' import { InfiniteData, @@ -11,7 +12,11 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' -import {embedViewRecordToPostView, getEmbeddedPost} from './util' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' const searchPostsQueryKeyRoot = 'search-posts' const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [ @@ -62,17 +67,20 @@ export function* findAllPostsInQueryData( >({ queryKey: [searchPostsQueryKeyRoot], }) + const atUri = new AtUri(uri) + for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { continue } for (const page of queryData?.pages) { for (const post of page.posts) { - if (post.uri === uri) { + if (didOrHandleUriMatches(atUri, post)) { yield post } + const quotedPost = getEmbeddedPost(post.embed) - if (quotedPost?.uri === uri) { + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPostView(quotedPost) } } diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index b74893fcd1..f733c37886 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -1,8 +1,10 @@ import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, + AtUri, } from '@atproto/api' import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query' @@ -22,6 +24,23 @@ export function truncateAndInvalidate( queryClient.invalidateQueries({queryKey}) } +// Given an AtUri, this function will check if the AtUri matches a +// hit regardless of whether the AtUri uses a DID or handle as a host. +// +// AtUri should be the URI that is being searched for, while currentUri +// is the URI that is being checked. currentAuthor is the author +// of the currentUri that is being checked. +export function didOrHandleUriMatches( + atUri: AtUri, + record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic}, +) { + if (atUri.host.startsWith('did:')) { + return atUri.href === record.uri + } + + return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey) +} + export function getEmbeddedPost( v: unknown, ): AppBskyEmbedRecord.ViewRecord | undefined { diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index daf8d70c2c..c8c1e103fb 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -50,7 +50,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -59,6 +58,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -87,7 +88,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -96,6 +96,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -136,7 +138,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -145,6 +146,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -183,7 +186,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -192,10 +194,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -204,6 +207,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -242,7 +247,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -251,10 +255,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -263,6 +268,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -299,7 +306,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -308,10 +314,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "jay-refresh-jwt-1", "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -320,10 +327,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -332,6 +340,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -364,7 +374,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -373,10 +382,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -385,10 +395,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": undefined, - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -397,6 +408,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -446,7 +459,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -455,6 +467,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -490,7 +504,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -499,6 +512,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -601,7 +616,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -610,6 +624,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -681,7 +697,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -690,6 +705,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -731,7 +748,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-3", - "deactivated": false, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": true, @@ -740,6 +756,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-3", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -781,7 +799,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-4", - "deactivated": false, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": false, @@ -790,6 +807,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-4", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -937,7 +956,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -946,10 +964,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-1", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -958,6 +977,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -997,7 +1018,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-2", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1006,10 +1026,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-2", "service": "https://bob.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "alice-access-jwt-2", - "deactivated": false, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -1018,6 +1039,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1156,7 +1179,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1165,6 +1187,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "alice-refresh-jwt-1", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1218,7 +1242,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1227,6 +1250,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1280,7 +1305,6 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, - "deactivated": false, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1289,6 +1313,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": undefined, "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1371,7 +1397,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", - "deactivated": false, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -1380,10 +1405,11 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "jay-refresh-jwt-1", "service": "https://jay.com/", + "signupQueued": false, + "status": "active", }, { "accessJwt": "bob-access-jwt-2", - "deactivated": false, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1392,6 +1418,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "bob-refresh-jwt-2", "service": "https://alice.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { @@ -1429,7 +1457,6 @@ describe('session', () => { "accounts": [ { "accessJwt": "clarence-access-jwt-2", - "deactivated": false, "did": "clarence-did", "email": undefined, "emailAuthFactor": false, @@ -1438,6 +1465,8 @@ describe('session', () => { "pdsUrl": undefined, "refreshJwt": "clarence-refresh-jwt-2", "service": "https://clarence.com/", + "signupQueued": false, + "status": "active", }, ], "currentAgentState": { diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 45013debc2..cdd24cd15a 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -16,7 +16,7 @@ import { configureModerationForGuest, } from './moderation' import {SessionAccount} from './types' -import {isSessionDeactivated, isSessionExpired} from './util' +import {isSessionExpired, isSignupQueued} from './util' export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests @@ -51,7 +51,7 @@ export async function createAgentAndResume( await networkRetry(1, () => agent.resumeSession(prevSession)) } else { agent.session = prevSession - if (!storedAccount.deactivated) { + if (!storedAccount.signupQueued) { // Intentionally not awaited to unblock the UI: networkRetry(3, () => agent.resumeSession(prevSession)).catch( (e: any) => { @@ -135,7 +135,7 @@ export async function createAgentAndCreateAccount( const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - if (!account.deactivated) { + if (!account.signupQueued) { /*dont await*/ agent.upsertProfile(_existing => { return { displayName: '', @@ -234,7 +234,9 @@ export function agentToSessionAccount( emailAuthFactor: agent.session.emailAuthFactor || false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, - deactivated: isSessionDeactivated(agent.session.accessJwt), + signupQueued: isSignupQueued(agent.session.accessJwt), + // @ts-expect-error TODO remove when backend is ready + status: agent.session.status || 'active', pdsUrl: agent.pdsUrl?.toString(), } } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e38dd2bb55..371bd459ad 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -17,7 +17,7 @@ import { } from './agent' import {getInitialState, reducer} from './reducer' -export {isSessionDeactivated} from './util' +export {isSignupQueued} from './util' export type {SessionAccount} from '#/state/session/types' import {SessionApiContext, SessionStateContext} from '#/state/session/types' diff --git a/src/state/session/util.ts b/src/state/session/util.ts index 8948ecd6b9..3a5909e825 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -10,11 +10,12 @@ export function readLastActiveAccount() { return accounts.find(a => a.did === currentAccount?.did) } -export function isSessionDeactivated(accessJwt: string | undefined) { +export function isSignupQueued(accessJwt: string | undefined) { if (accessJwt) { const sessData = jwtDecode(accessJwt) return ( - hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated' + hasProp(sessData, 'scope') && + sessData.scope === 'com.atproto.signupQueued' ) } return false diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b1c020a105..58ec65a883 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -9,6 +9,7 @@ import React, { import { ActivityIndicator, Keyboard, + LayoutChangeEvent, StyleSheet, TouchableOpacity, View, @@ -116,7 +117,7 @@ export const ComposePost = observer(function ComposePost({ const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') - const {isTabletOrDesktop, isMobile} = useWebMediaQueries() + const {isMobile} = useWebMediaQueries() const {_} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() @@ -170,24 +171,8 @@ export const ComposePost = observer(function ComposePost({ [insets, isKeyboardVisible], ) - const hasScrolled = useSharedValue(0) - const scrollHandler = useAnimatedScrollHandler({ - onScroll: event => { - hasScrolled.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) - }, - }) - const topBarAnimatedStyle = useAnimatedStyle(() => { - return { - borderColor: interpolateColor( - hasScrolled.value, - [0, 1], - ['transparent', t.atoms.border_contrast_medium.borderColor], - ), - } - }) - const onPressCancel = useCallback(() => { - if (graphemeLength > 0 || !gallery.isEmpty) { + if (graphemeLength > 0 || !gallery.isEmpty || extGif) { closeAllDialogs() if (Keyboard) { Keyboard.dismiss() @@ -197,6 +182,7 @@ export const ComposePost = observer(function ComposePost({ onClose() } }, [ + extGif, graphemeLength, gallery.isEmpty, closeAllDialogs, @@ -395,23 +381,26 @@ export const ComposePost = observer(function ComposePost({ [setExtLink], ) + const { + scrollHandler, + onScrollViewContentSizeChange, + onScrollViewLayout, + topBarAnimatedStyle, + bottomBarAnimatedStyle, + } = useAnimatedBorders() + return ( <> + keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}> - + + keyboardShouldPersistTaps="always" + onContentSizeChange={onScrollViewContentSizeChange} + onLayout={onScrollViewLayout}> {replyTo ? : undefined} {replyTo ? null : ( - + )} (null) } +function useAnimatedBorders() { + const t = useTheme() + const hasScrolledTop = useSharedValue(0) + const hasScrolledBottom = useSharedValue(0) + const contentOffset = useSharedValue(0) + const scrollViewHeight = useSharedValue(Infinity) + const contentHeight = useSharedValue(0) + + /** + * Make sure to run this on the UI thread! + */ + const showHideBottomBorder = useCallback( + ({ + newContentHeight, + newContentOffset, + newScrollViewHeight, + }: { + newContentHeight?: number + newContentOffset?: number + newScrollViewHeight?: number + }) => { + 'worklet' + + if (typeof newContentHeight === 'number') + contentHeight.value = Math.floor(newContentHeight) + if (typeof newContentOffset === 'number') + contentOffset.value = Math.floor(newContentOffset) + if (typeof newScrollViewHeight === 'number') + scrollViewHeight.value = Math.floor(newScrollViewHeight) + + hasScrolledBottom.value = withTiming( + contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value + ? 1 + : 0, + ) + }, + [contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom], + ) + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: event => { + 'worklet' + hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) + showHideBottomBorder({ + newContentOffset: event.contentOffset.y, + newContentHeight: event.contentSize.height, + newScrollViewHeight: event.layoutMeasurement.height, + }) + }, + }) + + const onScrollViewContentSizeChange = useCallback( + (_width: number, height: number) => { + 'worklet' + showHideBottomBorder({ + newContentHeight: height, + }) + }, + [showHideBottomBorder], + ) + + const onScrollViewLayout = useCallback( + (evt: LayoutChangeEvent) => { + 'worklet' + showHideBottomBorder({ + newScrollViewHeight: evt.nativeEvent.layout.height, + }) + }, + [showHideBottomBorder], + ) + + const topBarAnimatedStyle = useAnimatedStyle(() => { + return { + borderBottomWidth: hairlineWidth, + borderColor: interpolateColor( + hasScrolledTop.value, + [0, 1], + ['transparent', t.atoms.border_contrast_medium.borderColor], + ), + } + }) + const bottomBarAnimatedStyle = useAnimatedStyle(() => { + return { + borderTopWidth: hairlineWidth, + borderColor: interpolateColor( + hasScrolledBottom.value, + [0, 1], + ['transparent', t.atoms.border_contrast_medium.borderColor], + ), + } + }) + + return { + scrollHandler, + onScrollViewContentSizeChange, + onScrollViewLayout, + topBarAnimatedStyle, + bottomBarAnimatedStyle, + } +} + const styles = StyleSheet.create({ - topbar: { - borderBottomWidth: StyleSheet.hairlineWidth, - }, - topbarDesktop: { - paddingTop: 10, - paddingBottom: 10, - height: 50, - }, topbarInner: { flexDirection: 'row', alignItems: 'center', @@ -698,7 +786,8 @@ const styles = StyleSheet.create({ bottomBar: { flexDirection: 'row', paddingVertical: 4, - paddingLeft: 8, + // should be 8 but due to visual alignment we have to fudge it + paddingLeft: 7, paddingRight: 16, alignItems: 'center', borderTopWidth: hairlineWidth, diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index 60cef9a192..d13df0a110 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useRef} from 'react' import {Keyboard} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig' import {Gif} from '#/state/queries/tenor' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' @@ -19,14 +18,14 @@ type Props = { export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { const {_} = useLingui() - const control = useDialogControl() + const ref = useRef<{open: () => void}>(null) const t = useTheme() const onPressSelectGif = useCallback(async () => { logEvent('composer:gif:open', {}) Keyboard.dismiss() - control.open() - }, [control]) + ref.current?.open() + }, []) return ( <> @@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index afc9f5bfad..2aefdfbbf3 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {Keyboard, View} from 'react-native' +import {Keyboard, StyleProp, ViewStyle} from 'react-native' +import Animated, {AnimatedStyle} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -16,9 +17,11 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' export function ThreadgateBtn({ threadgate, onChange, + style, }: { threadgate: ThreadgateSetting[] onChange: (v: ThreadgateSetting[]) => void + style?: StyleProp> }) { const {track} = useAnalytics() const {_} = useLingui() @@ -46,7 +49,7 @@ export function ThreadgateBtn({ : _(msg`Some people can reply`) return ( - + - + ) } diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 670cd3e11c..197f35e4d0 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -7,23 +7,22 @@ import { View, ViewStyle, } from 'react-native' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isNative} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {hydrateFeedGenerator} from '#/state/queries/feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' -import {usePalette} from 'lib/hooks/usePalette' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {Text} from '../util/text/Text' import {FeedSourceCardLoaded} from './FeedSourceCard' const LOADING = {_reactKey: '__loading__'} @@ -52,7 +51,6 @@ export const ProfileFeedgens = React.forwardRef< {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, ref, ) { - const pal = usePalette('default') const {_} = useLingui() const theme = useTheme() const [isPTRing, setIsPTRing] = React.useState(false) @@ -138,13 +136,11 @@ export const ProfileFeedgens = React.forwardRef< ({item, index}: ListRenderItemInfo) => { if (item === EMPTY) { return ( - - - You have no feeds. - - + /> ) } else if (item === ERROR_ITEM) { return ( @@ -170,13 +166,13 @@ export const ProfileFeedgens = React.forwardRef< preferences={preferences} style={styles.item} showLikes - hideTopBorder={index === 0} + hideTopBorder={index === 0 && !isWeb} /> ) } return null }, - [error, refetch, onPressRetryLoadMore, pal, preferences, _], + [error, refetch, onPressRetryLoadMore, preferences, _], ) React.useEffect(() => { diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx index 5ea95971ca..472d2688c7 100644 --- a/src/view/com/lists/MyLists.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -9,7 +9,8 @@ import { ViewStyle, } from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -17,11 +18,10 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List} from '../util/List' -import {Text} from '../util/text/Text' import {ListCard} from './ListCard' -import hairlineWidth = StyleSheet.hairlineWidth const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -42,6 +42,7 @@ export function MyLists({ }) { const pal = usePalette('default') const {track} = useAnalytics() + const {_} = useLingui() const [isPTRing, setIsPTRing] = React.useState(false) const {data, isFetching, isFetched, isError, error, refetch} = useMyListsQuery(filter) @@ -83,14 +84,12 @@ export function MyLists({ ({item, index}: {item: any; index: number}) => { if (item === EMPTY) { return ( - - - You have no lists. - - + /> ) } else if (item === ERROR_ITEM) { return ( @@ -118,7 +117,7 @@ export function MyLists({ /> ) }, - [error, onRefresh, renderItem, pal], + [error, onRefresh, renderItem, _], ) if (inline) { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index d1ef05f124..e7fdfe4bd5 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -7,22 +7,21 @@ import { View, ViewStyle, } from 'react-native' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isNative} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {EmptyState} from 'view/com/util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {Text} from '../util/text/Text' import {ListCard} from './ListCard' const LOADING = {_reactKey: '__loading__'} @@ -49,7 +48,6 @@ export const ProfileLists = React.forwardRef( {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, ref, ) { - const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() @@ -142,11 +140,11 @@ export const ProfileLists = React.forwardRef( ({item, index}: ListRenderItemInfo) => { if (item === EMPTY) { return ( - - - You have no lists. - - + ) } else if (item === ERROR_ITEM) { return ( @@ -172,11 +170,11 @@ export const ProfileLists = React.forwardRef( list={item} testID={`list-${item.name}`} style={styles.item} - noBorder={index === 0} + noBorder={index === 0 && !isWeb} /> ) }, - [error, refetch, onPressRetryLoadMore, pal, _], + [error, refetch, onPressRetryLoadMore, _], ) React.useEffect(() => { diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 06f1e111a0..6dd248ca7e 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -18,7 +18,13 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' import {colors, gradients, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' -import {isAndroid} from 'platform/detection' +import {isAndroid, isWeb} from 'platform/detection' +import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' +import {atoms as a, useTheme as useNewTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLinkText} from '#/components/Link' +import {Text as NewText} from '#/components/Typography' import {resetToTab} from '../../../Navigation' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' @@ -30,6 +36,7 @@ export const snapPoints = isAndroid ? ['90%'] : ['55%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() + const t = useNewTheme() const {currentAccount} = useSession() const agent = useAgent() const {removeAccount} = useSessionApi() @@ -41,6 +48,7 @@ export function Component({}: {}) { const [password, setPassword] = React.useState('') const [isProcessing, setIsProcessing] = React.useState(false) const [error, setError] = React.useState('') + const deactivateAccountControl = useDialogControl() const onPressSendEmail = async () => { setError('') setIsProcessing(true) @@ -168,6 +176,50 @@ export function Component({}: {}) { )} + + + + + + + + You can also temporarily deactivate your account instead, + and reactivate it at any time. + {' '} + { + e.preventDefault() + deactivateAccountControl.open() + return false + }}> + Click here for more information. + + + + + + ) : ( <> diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 8a61b1a707..88506da570 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -6,28 +6,30 @@ import { View, } from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import {MyLists} from '../lists/MyLists' -import {Button} from '../util/forms/Button' -import * as Toast from '../util/Toast' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid, isMobileWeb} from 'platform/detection' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' import {useModalControls} from '#/state/modals' import { - useDangerousListMembershipsQuery, getMembership, ListMembersip, + useDangerousListMembershipsQuery, useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' -import {cleanError} from '#/lib/strings/errors' import {useSession} from '#/state/session' +import {usePalette} from 'lib/hooks/usePalette' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {s} from 'lib/styles' +import {isAndroid, isMobileWeb, isWeb} from 'platform/detection' +import {MyLists} from '../lists/MyLists' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' +import {UserAvatar} from '../util/UserAvatar' +import hairlineWidth = StyleSheet.hairlineWidth export const snapPoints = ['fullscreen'] @@ -61,12 +63,23 @@ export function Component({ return [pal.border, {height: screenHeight / 1.5}] } - return [pal.border, {flex: 1}] + return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}] }, [pal.border, screenHeight]) return ( - + Update {displayName} in Lists @@ -229,16 +240,6 @@ const styles = StyleSheet.create({ container: { paddingHorizontal: isWeb ? 0 : 16, }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, - marginBottom: 10, - }, - list: { - flex: 1, - borderTopWidth: 1, - }, btns: { position: 'relative', flexDirection: 'row', @@ -247,7 +248,7 @@ const styles = StyleSheet.create({ gap: 10, paddingTop: 10, paddingBottom: isAndroid ? 10 : 0, - borderTopWidth: 1, + borderTopWidth: hairlineWidth, }, footerBtn: { paddingHorizontal: 24, diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 096305a230..4827aef512 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -40,7 +40,6 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostHider} from '../../../components/moderation/PostHider' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {AviFollowButton} from '../posts/AviFollowButton' import {WhoCanReply} from '../threadgate/WhoCanReply' import {ErrorMessage} from '../util/error/ErrorMessage' import {Link, TextLink} from '../util/Link' @@ -472,16 +471,12 @@ let PostThreadItemLoaded = ({ {/* If we are in threaded mode, the avatar is rendered in PostMeta */} {!isThreadedChild && ( - - - + {showChildReplyLine && ( void) | null>(null) const lastFetchRef = React.useRef(Date.now()) const [feedType, feedUri] = feed.split('|') - const {isTabletOrMobile} = useWebMediaQueries() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -314,15 +312,9 @@ let Feed = ({ // -prf return } - return ( - - ) + return }, [ - isTabletOrMobile, renderEmptyState, feed, error, diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 72c8b8757a..675f23a88c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -43,6 +43,7 @@ import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' import {AviFollowButton} from './AviFollowButton' import hairlineWidth = StyleSheet.hairlineWidth +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,13 +252,10 @@ let FeedItemInner = ({ )}`, )} onBeforePress={onOpenReposter}> - + {icon === 'user-group' ? ( @@ -48,9 +51,9 @@ export function EmptyState({ const styles = StyleSheet.create({ container: { - paddingVertical: 20, + paddingVertical: 24, paddingHorizontal: 36, - borderTopWidth: 1, + borderTopWidth: isWeb ? 1 : undefined, }, iconContainer: { flexDirection: 'row', diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index ffea9fe2e6..21998bfbdd 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -32,14 +32,17 @@ interface AddedProps { desktopFixedHeight?: boolean | number } -export const CenteredView = React.forwardRef(function CenteredView({ - style, - sideBorders, - topBorder, - ...props -}: React.PropsWithChildren< - ViewProps & {sideBorders?: boolean; topBorder?: boolean} ->) { +export const CenteredView = React.forwardRef(function CenteredView( + { + style, + sideBorders, + topBorder, + ...props + }: React.PropsWithChildren< + ViewProps & {sideBorders?: boolean; topBorder?: boolean} + >, + ref: React.Ref, +) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() if (!isMobile) { @@ -58,7 +61,7 @@ export const CenteredView = React.forwardRef(function CenteredView({ }) style = addStyle(style, pal.border) } - return + return }) export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl( diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index cdbdafc9ba..d7624b4310 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -2,7 +2,6 @@ import React from 'react' import { StyleProp, StyleSheet, - TextStyle, TouchableOpacity, View, ViewStyle, @@ -32,7 +31,7 @@ import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' import {precacheProfile} from 'state/queries/profile' import {ComposerOptsQuote} from 'state/shell/composer' -import {atoms as a, flatten} from '#/alf' +import {atoms as a} from '#/alf' import {RichText} from '#/components/RichText' import {ContentHider} from '../../../../components/moderation/ContentHider' import {PostAlerts} from '../../../../components/moderation/PostAlerts' @@ -46,12 +45,12 @@ export function MaybeQuoteEmbed({ embed, onOpen, style, - textStyle, + allowNestedQuotes, }: { embed: AppBskyEmbedRecord.View onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const pal = usePalette('default') if ( @@ -65,7 +64,7 @@ export function MaybeQuoteEmbed({ postRecord={embed.record.value} onOpen={onOpen} style={style} - textStyle={textStyle} + allowNestedQuotes={allowNestedQuotes} /> ) } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { @@ -95,13 +94,13 @@ function QuoteEmbedModerated({ postRecord, onOpen, style, - textStyle, + allowNestedQuotes, }: { viewRecord: AppBskyEmbedRecord.ViewRecord postRecord: AppBskyFeedPost.Record onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const moderationOpts = useModerationOpts() const moderation = React.useMemo(() => { @@ -126,7 +125,7 @@ function QuoteEmbedModerated({ moderation={moderation} onOpen={onOpen} style={style} - textStyle={textStyle} + allowNestedQuotes={allowNestedQuotes} /> ) } @@ -136,13 +135,13 @@ export function QuoteEmbed({ moderation, onOpen, style, - textStyle, + allowNestedQuotes, }: { quote: ComposerOptsQuote moderation?: ModerationDecision onOpen?: () => void style?: StyleProp - textStyle?: StyleProp + allowNestedQuotes?: boolean }) { const queryClient = useQueryClient() const pal = usePalette('default') @@ -161,16 +160,20 @@ export function QuoteEmbed({ const embed = React.useMemo(() => { const e = quote.embeds?.[0] - if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + if (allowNestedQuotes) { return e - } else if ( - AppBskyEmbedRecordWithMedia.isView(e) && - (AppBskyEmbedImages.isView(e.media) || - AppBskyEmbedExternal.isView(e.media)) - ) { - return e.media + } else { + if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + return e + } else if ( + AppBskyEmbedRecordWithMedia.isView(e) && + (AppBskyEmbedImages.isView(e.media) || + AppBskyEmbedExternal.isView(e.media)) + ) { + return e.media + } } - }, [quote.embeds]) + }, [quote.embeds, allowNestedQuotes]) const onBeforePress = React.useCallback(() => { precacheProfile(queryClient, quote.author) @@ -201,7 +204,7 @@ export function QuoteEmbed({ {richText ? ( diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 962f3d8c51..a13fffc370 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -4,7 +4,6 @@ import { StyleProp, StyleSheet, Text, - TextStyle, View, ViewStyle, } from 'react-native' @@ -42,13 +41,13 @@ export function PostEmbeds({ moderation, onOpen, style, - quoteTextStyle, + allowNestedQuotes, }: { embed?: Embed moderation?: ModerationDecision onOpen?: () => void style?: StyleProp - quoteTextStyle?: StyleProp + allowNestedQuotes?: boolean }) { const pal = usePalette('default') const {openLightbox} = useLightboxControls() @@ -63,11 +62,7 @@ export function PostEmbeds({ moderation={moderation} onOpen={onOpen} /> - + ) } @@ -98,8 +93,8 @@ export function PostEmbeds({ ) } diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index 0dd2febcb6..12ea6f48be 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -52,12 +52,12 @@ export function ListsScreen({}: Props) { + style={[ + pal.border, + isMobile + ? {borderBottomWidth: hairlineWidth} + : {borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth}, + ]}> User Lists diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index ba1fa130ee..70378f4b81 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,28 +1,26 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' -import {ComposePrompt} from 'view/com/composer/Prompt' -import {s} from 'lib/styles' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {clamp} from 'lodash' + +import {isWeb} from '#/platform/detection' import { RQKEY as POST_THREAD_RQKEY, ThreadNode, } from '#/state/queries/post-thread' -import {clamp} from 'lodash' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' -import {useSetMinimalShellMode} from '#/state/shell' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {ErrorMessage} from '../com/util/error/ErrorMessage' -import {CenteredView} from '../com/util/Views' -import {useComposerControls} from '#/state/shell/composer' import {useSession} from '#/state/session' -import {isWeb} from '#/platform/detection' +import {useSetMinimalShellMode} from '#/state/shell' +import {useComposerControls} from '#/state/shell/composer' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {s} from 'lib/styles' +import {ComposePrompt} from 'view/com/composer/Prompt' +import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { @@ -35,7 +33,6 @@ export function PostThreadScreen({route}: Props) { const {name, rkey} = route.params const {isMobile} = useWebMediaQueries() const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri) const [canReply, setCanReply] = React.useState(false) useFocusEffect( @@ -45,12 +42,10 @@ export function PostThreadScreen({route}: Props) { ) const onPressReply = React.useCallback(() => { - if (!resolvedUri) { + if (!uri) { return } - const thread = queryClient.getQueryData( - POST_THREAD_RQKEY(resolvedUri.uri), - ) + const thread = queryClient.getQueryData(POST_THREAD_RQKEY(uri)) if (thread?.type !== 'post') { return } @@ -64,25 +59,19 @@ export function PostThreadScreen({route}: Props) { }, onPost: () => queryClient.invalidateQueries({ - queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), + queryKey: POST_THREAD_RQKEY(uri), }), }) - }, [openComposer, queryClient, resolvedUri]) + }, [openComposer, queryClient, uri]) return ( - {uriError ? ( - - - - ) : ( - - )} + {isMobile && canReply && hasSession && ( ([]) + const [selectedProfiles, setSelectedProfiles] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) useFocusEffect( useNonReactiveCallback(() => { @@ -504,6 +514,10 @@ export function SearchScreen( if (history !== null) { setSearchHistory(JSON.parse(history)) } + const profiles = await AsyncStorage.getItem('selectedProfiles') + if (profiles !== null) { + setSelectedProfiles(JSON.parse(profiles)) + } } catch (e: any) { logger.error('Failed to load search history', {message: e}) } @@ -562,6 +576,30 @@ export function SearchScreen( [searchHistory, setSearchHistory], ) + const updateSelectedProfiles = React.useCallback( + async (profile: AppBskyActorDefs.ProfileViewBasic) => { + let newProfiles = [ + profile, + ...selectedProfiles.filter(p => p.did !== profile.did), + ] + + if (newProfiles.length > 5) { + newProfiles = newProfiles.slice(0, 5) + } + + setSelectedProfiles(newProfiles) + try { + await AsyncStorage.setItem( + 'selectedProfiles', + JSON.stringify(newProfiles), + ) + } catch (e: any) { + logger.error('Failed to save selected profiles', {message: e}) + } + }, + [selectedProfiles, setSelectedProfiles], + ) + const navigateToItem = React.useCallback( (item: string) => { scrollToTopWeb() @@ -598,6 +636,16 @@ export function SearchScreen( [navigateToItem], ) + const handleProfileClick = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + // Slight delay to avoid updating during push nav animation. + setTimeout(() => { + updateSelectedProfiles(profile) + }, 400) + }, + [updateSelectedProfiles], + ) + const onSoftReset = React.useCallback(() => { if (isWeb) { // Empty params resets the URL to be /search rather than /search?q= @@ -629,6 +677,22 @@ export function SearchScreen( [searchHistory], ) + const handleRemoveProfile = React.useCallback( + (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => { + const updatedProfiles = selectedProfiles.filter( + profile => profile.did !== profileToRemove.did, + ) + setSelectedProfiles(updatedProfiles) + AsyncStorage.setItem( + 'selectedProfiles', + JSON.stringify(updatedProfiles), + ).catch(e => { + logger.error('Failed to update selected profiles', {message: e}) + }) + }, + [selectedProfiles], + ) + return ( ) : ( )} @@ -814,12 +882,14 @@ let AutocompleteResults = ({ searchText, onSubmit, onResultPress, + onProfileClick, }: { isAutocompleteFetching: boolean autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined searchText: string onSubmit: () => void onResultPress: () => void + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void }): React.ReactNode => { const moderationOpts = useModerationOpts() const {_} = useLingui() @@ -850,7 +920,10 @@ let AutocompleteResults = ({ key={item.did} profile={item} moderation={moderateProfile(item, moderationOpts)} - onPress={onResultPress} + onPress={() => { + onProfileClick(item) + onResultPress() + }} /> ))} @@ -861,17 +934,32 @@ let AutocompleteResults = ({ } AutocompleteResults = React.memo(AutocompleteResults) +function truncateText(text: string, maxLength: number) { + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...' + } + return text +} + function SearchHistory({ searchHistory, + selectedProfiles, onItemClick, + onProfileClick, onRemoveItemClick, + onRemoveProfileClick, }: { searchHistory: string[] + selectedProfiles: AppBskyActorDefs.ProfileViewBasic[] onItemClick: (item: string) => void + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void onRemoveItemClick: (item: string) => void + onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void }) { - const {isTabletOrDesktop} = useWebMediaQueries() + const {isTabletOrDesktop, isMobile} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() + return ( + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + + Recent Searches + + )} + {selectedProfiles.length > 0 && ( + + + {selectedProfiles.slice(0, 5).map((profile, index) => ( + + onProfileClick(profile)} + style={styles.profilePressable}> + } + accessibilityIgnoresInvertColors + /> + + {truncateText(profile.displayName || '', 12)} + + + onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + + + + ))} + + + )} {searchHistory.length > 0 && ( - - Recent Searches - - {searchHistory.map((historyItem, index) => ( + {searchHistory.slice(0, 5).map((historyItem, index) => ( - + {profile?.displayName || account.handle} - + {account.handle} @@ -307,6 +308,11 @@ export function SettingsScreen({}: Props) { Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`)) }, [_]) + const deactivateAccountControl = useDialogControl() + const onPressDeactivateAccount = React.useCallback(() => { + deactivateAccountControl.open() + }, [deactivateAccountControl]) + const {mutate: onPressDeleteChatDeclaration} = useDeleteActorDeclaration() return ( @@ -381,7 +387,7 @@ export function SettingsScreen({}: Props) { {!currentAccount.emailConfirmed && } - + Signed in as @@ -791,6 +797,29 @@ export function SettingsScreen({}: Props) { Export My Data + + + + + + + Deactivate my account + + + + - + - + - + ) } @@ -94,12 +85,12 @@ const styles = StyleSheet.create({ maxWidth: 600, width: '100%', paddingVertical: 0, - paddingHorizontal: 2, borderRadius: 8, marginBottom: 0, borderWidth: 1, // @ts-ignore web only maxHeight: 'calc(100% - (40px * 2))', + overflow: 'hidden', }, containerMobile: { borderRadius: 0, diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 882fdbe6e2..3c611351dc 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -32,6 +32,7 @@ import { import {isWeb} from 'platform/detection' import {Deactivated} from '#/screens/Deactivated' import {Onboarding} from '#/screens/Onboarding' +import {SignupQueued} from '#/screens/SignupQueued' import {LoggedOut} from '../com/auth/LoggedOut' import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {DesktopLeftNav} from './desktop/LeftNav' @@ -102,12 +103,15 @@ function NativeStackNavigator({ if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { return } - if (hasSession && currentAccount?.deactivated) { - return + if (hasSession && currentAccount?.signupQueued) { + return } if (showLoggedOut) { return setShowLoggedOut(false)} /> } + if (currentAccount?.status === 'deactivated') { + return + } if (onboardingState.isActive) { return }