Compare commits
26 Commits
ls
...
schema-errors
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d840faacc | |||
| ded615d9f9 | |||
| 551af88f22 | |||
| a49fe13223 | |||
| 9f001526d3 | |||
| d6b8313932 | |||
| e4b4d854d6 | |||
| c352e0f8b4 | |||
| e7968bc8d7 | |||
| 6f1589971c | |||
| 2ffb98e22a | |||
| d918f8dc2a | |||
| d032734278 | |||
| 3e1f076891 | |||
| de93e8de74 | |||
| da96fb1ef5 | |||
| b02445883a | |||
| bd4703ca1e | |||
| 891b432ead | |||
| 3b55f61d5f | |||
| 8c596b61c0 | |||
| 44670c7ee2 | |||
| 8d8323421c | |||
| 21d4d5f600 | |||
| bda10510a4 | |||
| 16f295ca85 |
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 514 B |
@@ -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": {
|
||||
|
||||
@@ -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) => <ModalError details={String(error)} close={close} />,
|
||||
[close],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="slide"
|
||||
presentationStyle="formSheet"
|
||||
onRequestClose={close}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<View style={[a.flex_1, t.atoms.bg]}>
|
||||
<Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList onSelectGif={onSelectGif} close={close} />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
onSelectGif,
|
||||
}: {
|
||||
close: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<FlatList_INTERNAL>(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 <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[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 (
|
||||
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _])
|
||||
|
||||
return (
|
||||
<FlatList_INTERNAL
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
contentContainerStyle={a.px_lg}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalError({details, close}: {details?: string; close: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[a.flex_1, a.gap_md]}
|
||||
centerContent
|
||||
contentContainerStyle={a.px_lg}>
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
onPress={close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
@@ -14,7 +14,7 @@ let MessageItemEmbed = ({
|
||||
|
||||
return (
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds embed={embed} />
|
||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
@@ -55,9 +56,11 @@ type Item =
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
onSelectChat,
|
||||
showRecentConvos,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
showRecentConvos?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -75,6 +78,7 @@ export function SearchablePeopleList({
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
@@ -103,7 +107,65 @@ export function SearchablePeopleList({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (follows) {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (showRecentConvos) {
|
||||
if (convos && follows) {
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: typeof _items = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
|
||||
// then append
|
||||
_items.push(...followsItems)
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
} else if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
@@ -120,19 +182,21 @@ export function SearchablePeopleList({
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
Array(10)
|
||||
.fill(0)
|
||||
.forEach((_, i) => {
|
||||
_items.push({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
})
|
||||
})
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [_, searchText, results, isError, currentAccount?.did, follows])
|
||||
}, [
|
||||
_,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
convos,
|
||||
showRecentConvos,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
|
||||
@@ -46,6 +46,7 @@ export function SendViaChatDialog({
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export type LogEvents = {
|
||||
withPassword: boolean
|
||||
}
|
||||
'account:loggedOut': {
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'notifications:request': {
|
||||
|
||||
@@ -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<EmbedPlayerSource, string> = {
|
||||
youtube: 'YouTube',
|
||||
@@ -53,6 +55,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
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({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+138
-183
@@ -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<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
|
||||
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 = (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Check my status`)}
|
||||
onPress={checkStatus}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Check my status</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
},
|
||||
[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 (
|
||||
<View
|
||||
aria-modal
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`You're in line`)}
|
||||
accessibilityLabel={_(msg`You're in line`)}
|
||||
accessibilityHint=""
|
||||
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
|
||||
<View style={[a.h_full_vh, a.flex_1, t.atoms.bg]}>
|
||||
<ScrollView
|
||||
style={[a.h_full, a.w_full]}
|
||||
contentContainerStyle={{borderWidth: 0}}>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
|
||||
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
|
||||
<Logo width={120} />
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
|
||||
<Trans>You're in line</Trans>
|
||||
</Text>
|
||||
<P style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
There's been a rush of new users to Bluesky! We'll activate your
|
||||
account as soon as we can.
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_2xl,
|
||||
a.py_4xl,
|
||||
a.mt_2xl,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{typeof placeInQueue === 'number' && (
|
||||
<Text
|
||||
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
|
||||
{placeInQueue}
|
||||
</Text>
|
||||
)}
|
||||
<P style={[a.text_center]}>
|
||||
{typeof placeInQueue === 'number' ? (
|
||||
<Trans>left to go.</Trans>
|
||||
) : (
|
||||
<Trans>You are in line.</Trans>
|
||||
)}{' '}
|
||||
{estimatedTime ? (
|
||||
<Trans>
|
||||
We estimate {estimatedTime} until your account is ready.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We will let you know when your account is ready.
|
||||
</Trans>
|
||||
)}
|
||||
</P>
|
||||
</View>
|
||||
|
||||
{isWeb && gtMobile && (
|
||||
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('Deactivated')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{checkBtn}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{(!isWeb || !gtMobile) && (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
|
||||
paddingTop: isWeb ? 64 : insets.top,
|
||||
paddingBottom: isWeb ? 64 : insets.bottom,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
|
||||
{checkBtn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('Deactivated')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={[a.flex_row, a.justify_center]}>
|
||||
<View style={[a.w_full, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.pb_5xl]}>
|
||||
<Logo width={40} />
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xs, a.pb_3xl]}>
|
||||
<Text style={[a.text_xl, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Welcome back!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
<Trans>
|
||||
You previously deactivated @{currentAccount?.handle}.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_sm, a.leading_snug, a.pb_md]}>
|
||||
<Trans>
|
||||
You can reactivate your account to continue logging in. Your
|
||||
profile and posts will be visible to other users.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Button
|
||||
label={_(msg`Reactivate your account`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => setShowLoggedOut(true)}>
|
||||
<ButtonText>
|
||||
<Trans>Yes, reactivate my account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Cancel reactivation and log out`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={onPressLogout}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_3xl]}>
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
{hasOtherAccounts ? (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Or, log into one of your other accounts.</Trans>
|
||||
</Text>
|
||||
<AccountList
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
pendingDid={pendingDid}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Or, continue with another account.</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Log in or sign up`)}
|
||||
size="medium"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => setShowLoggedOut(true)}>
|
||||
<ButtonText>
|
||||
<Trans>Log in or sign up</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -312,25 +312,19 @@ export function MessagesList({
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
// remove the post link from the text
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// re-trim the text, now that we've removed the post link
|
||||
//
|
||||
// if the post link is at the start of the text, we don't want to leave a leading space
|
||||
// so trim on both sides
|
||||
if (postLinkFacet.index.byteStart === 0) {
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
} else {
|
||||
// otherwise just trim the end
|
||||
rt = new RichText(
|
||||
{text: rt.text.trimEnd()},
|
||||
{cleanNewlines: true},
|
||||
// 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Prompt.Outer control={control} testID="confirmModal">
|
||||
<Prompt.TitleText>{_(msg`Deactivate account`)}</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
|
||||
<View style={[a.pb_xl]}>
|
||||
<Divider />
|
||||
<View style={[a.gap_sm, a.pt_lg, a.pb_xl]}>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
There is no time limit for account deactivation, come back any
|
||||
time.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
If you're trying to change your handle or email, do so before you
|
||||
deactivate.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
</View>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
cta={_(msg`Yes, deactivate`)}
|
||||
onPress={() => {}}
|
||||
color="negative"
|
||||
/>
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -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<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
|
||||
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 = (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Check my status`)}
|
||||
onPress={checkStatus}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Check my status</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
aria-modal
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`You're in line`)}
|
||||
accessibilityLabel={_(msg`You're in line`)}
|
||||
accessibilityHint=""
|
||||
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
|
||||
<ScrollView
|
||||
style={[a.h_full, a.w_full]}
|
||||
contentContainerStyle={{borderWidth: 0}}>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
|
||||
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
|
||||
<Logo width={120} />
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
|
||||
<Trans>You're in line</Trans>
|
||||
</Text>
|
||||
<P style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
There's been a rush of new users to Bluesky! We'll activate your
|
||||
account as soon as we can.
|
||||
</Trans>
|
||||
</P>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_2xl,
|
||||
a.py_4xl,
|
||||
a.mt_2xl,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{typeof placeInQueue === 'number' && (
|
||||
<Text
|
||||
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
|
||||
{placeInQueue}
|
||||
</Text>
|
||||
)}
|
||||
<P style={[a.text_center]}>
|
||||
{typeof placeInQueue === 'number' ? (
|
||||
<Trans>left to go.</Trans>
|
||||
) : (
|
||||
<Trans>You are in line.</Trans>
|
||||
)}{' '}
|
||||
{estimatedTime ? (
|
||||
<Trans>
|
||||
We estimate {estimatedTime} until your account is ready.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We will let you know when your account is ready.
|
||||
</Trans>
|
||||
)}
|
||||
</P>
|
||||
</View>
|
||||
|
||||
{isWeb && gtMobile && (
|
||||
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{checkBtn}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{(!isWeb || !gtMobile) && (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
|
||||
},
|
||||
]}>
|
||||
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
|
||||
{checkBtn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<typeof accountSchema>
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
import {logger} from '#/logger'
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
@@ -13,8 +13,19 @@ export async function write(value: Schema) {
|
||||
export async function read(): Promise<Schema | undefined> {
|
||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||
if (schema.safeParse(objData).success) {
|
||||
const parsed = schema.safeParse(objData)
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path,
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,15 @@ import {useAgent, useSession} from '#/state/session'
|
||||
export const RQKEY = ['convo-list']
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useListConvosQuery() {
|
||||
export function useListConvosQuery({
|
||||
enabled,
|
||||
}: {
|
||||
enabled?: boolean
|
||||
} = {}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled,
|
||||
queryKey: RQKEY,
|
||||
queryFn: async ({pageParam}) => {
|
||||
const {data} = await agent.api.chat.bsky.convo.listConvos(
|
||||
|
||||
@@ -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<AppBskyFeedDefs.PostView, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
||||
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!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ async function fetchSubjects(
|
||||
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
|
||||
const uris = new Set<string>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppBskyFeedDefs.PostView, undefined> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<
|
||||
InfiniteData<FeedPageUnselected>
|
||||
>({
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ThreadNode, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<ThreadNode>({
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T = any>(
|
||||
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 {
|
||||
|
||||
@@ -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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
{
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"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": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
|
||||
@@ -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,
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior="padding"
|
||||
style={a.flex_1}
|
||||
keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}>
|
||||
keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}>
|
||||
<View
|
||||
style={[a.flex_1, viewStyles]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.topbar,
|
||||
topBarAnimatedStyle,
|
||||
isWeb && isTabletOrDesktop && styles.topbarDesktop,
|
||||
]}>
|
||||
<Animated.View style={topBarAnimatedStyle}>
|
||||
<View style={styles.topbarInner}>
|
||||
<TouchableOpacity
|
||||
testID="composerDiscardButton"
|
||||
@@ -509,7 +498,9 @@ export const ComposePost = observer(function ComposePost({
|
||||
<Animated.ScrollView
|
||||
onScroll={scrollHandler}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always">
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||
|
||||
<View
|
||||
@@ -575,7 +566,11 @@ export const ComposePost = observer(function ComposePost({
|
||||
<KeyboardStickyView
|
||||
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
|
||||
{replyTo ? null : (
|
||||
<ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} />
|
||||
<ThreadgateBtn
|
||||
threadgate={threadgate}
|
||||
onChange={setThreadgate}
|
||||
style={bottomBarAnimatedStyle}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
@@ -625,15 +620,108 @@ export function useComposerCancelRef() {
|
||||
return useRef<CancelRef>(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,
|
||||
|
||||
@@ -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) {
|
||||
</Button>
|
||||
|
||||
<GifSelectDialog
|
||||
control={control}
|
||||
controlRef={ref}
|
||||
onClose={onClose}
|
||||
onSelectGif={onSelectGif}
|
||||
/>
|
||||
|
||||
@@ -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<AnimatedStyle<ViewStyle>>
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
@@ -46,7 +49,7 @@ export function ThreadgateBtn({
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.py_xs, a.px_sm, t.atoms.bg]}>
|
||||
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -59,6 +62,6 @@ export function ThreadgateBtn({
|
||||
/>
|
||||
<ButtonText>{label}</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<View
|
||||
<EmptyState
|
||||
icon="hashtag"
|
||||
message={_(msg`You have no feeds.`)}
|
||||
testID="listsEmpty"
|
||||
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>You have no feeds.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
/>
|
||||
)
|
||||
} 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(() => {
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
<EmptyState
|
||||
key={item._reactKey}
|
||||
icon="list-ul"
|
||||
message={_(msg`You have no lists.`)}
|
||||
testID="listsEmpty"
|
||||
style={[{padding: 18, borderTopWidth: hairlineWidth}, pal.border]}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>You have no lists.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
@@ -118,7 +117,7 @@ export function MyLists({
|
||||
/>
|
||||
)
|
||||
},
|
||||
[error, onRefresh, renderItem, pal],
|
||||
[error, onRefresh, renderItem, _],
|
||||
)
|
||||
|
||||
if (inline) {
|
||||
|
||||
@@ -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<SectionRef, ProfileListsProps>(
|
||||
{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<SectionRef, ProfileListsProps>(
|
||||
({item, index}: ListRenderItemInfo<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<View testID="listsEmpty" style={[{padding: 18}, pal.border]}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>You have no lists.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<EmptyState
|
||||
icon="list-ul"
|
||||
message={_(msg`You have no lists.`)}
|
||||
testID="listsEmpty"
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
@@ -172,11 +170,11 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
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(() => {
|
||||
|
||||
@@ -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<string>('')
|
||||
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<string>('')
|
||||
const deactivateAccountControl = useDialogControl()
|
||||
const onPressSendEmail = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
@@ -168,6 +176,50 @@ export function Component({}: {}) {
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[!isWeb && a.px_xl]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.mt_lg,
|
||||
a.p_lg,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<CircleInfo
|
||||
size="md"
|
||||
style={[
|
||||
a.relative,
|
||||
{
|
||||
top: -1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<NewText style={[a.leading_snug, a.flex_1]}>
|
||||
<Trans>
|
||||
You can also temporarily deactivate your account instead,
|
||||
and reactivate it at any time.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
label={_(
|
||||
msg`Click here for more information on deactivating your account`,
|
||||
)}
|
||||
to="#"
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
deactivateAccountControl.open()
|
||||
return false
|
||||
}}>
|
||||
<Trans>Click here for more information.</Trans>
|
||||
</InlineLinkText>
|
||||
</NewText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DeactivateAccountDialog control={deactivateAccountControl} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 20,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
pal.text,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Update {displayName} in Lists</Trans>
|
||||
</Text>
|
||||
<MyLists
|
||||
@@ -175,9 +188,7 @@ function ListItem({
|
||||
style={[
|
||||
styles.listItem,
|
||||
pal.border,
|
||||
{
|
||||
borderTopWidth: index === 0 ? 0 : 1,
|
||||
},
|
||||
index !== 0 && {borderTopWidth: hairlineWidth},
|
||||
]}>
|
||||
<View style={styles.listItemAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} type="list" />
|
||||
@@ -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,
|
||||
|
||||
@@ -129,7 +129,11 @@ export function Feed({
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return (
|
||||
<View style={[pal.border, {borderTopWidth: hairlineWidth}]}>
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
!isTabletOrMobile && {borderTopWidth: hairlineWidth},
|
||||
]}>
|
||||
<NotificationFeedLoadingPlaceholder />
|
||||
</View>
|
||||
)
|
||||
@@ -185,6 +189,7 @@ export function Feed({
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
<View style={styles.layoutAvi}>
|
||||
<AviFollowButton author={post.author} moderation={moderation}>
|
||||
<PreviewableUserAvatar
|
||||
size={38}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={
|
||||
post.author.associated?.labeler ? 'labeler' : 'user'
|
||||
}
|
||||
/>
|
||||
</AviFollowButton>
|
||||
<PreviewableUserAvatar
|
||||
size={38}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
|
||||
{showChildReplyLine && (
|
||||
<View
|
||||
|
||||
@@ -97,8 +97,8 @@ export function AviFollowButton({
|
||||
}),
|
||||
a.absolute,
|
||||
{
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
right: -1,
|
||||
borderWidth: 1,
|
||||
borderColor: t.atoms.bg.backgroundColor,
|
||||
},
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export {Fragment as AviFollowButton} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export function AviFollowButton({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import {useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
@@ -102,7 +101,6 @@ let Feed = ({
|
||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||
const lastFetchRef = React.useRef<number>(Date.now())
|
||||
const [feedType, feedUri] = feed.split('|')
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
const opts = React.useMemo(
|
||||
() => ({enabled, ignoreFilterFor}),
|
||||
@@ -314,15 +312,9 @@ let Feed = ({
|
||||
// -prf
|
||||
return <DiscoverFallbackHeader />
|
||||
}
|
||||
return (
|
||||
<FeedSlice
|
||||
slice={item}
|
||||
hideTopBorder={index === 0 && isTabletOrMobile}
|
||||
/>
|
||||
)
|
||||
return <FeedSlice slice={item} hideTopBorder={index === 0 && !isWeb} />
|
||||
},
|
||||
[
|
||||
isTabletOrMobile,
|
||||
renderEmptyState,
|
||||
feed,
|
||||
error,
|
||||
|
||||
@@ -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}>
|
||||
<FontAwesomeIcon
|
||||
icon="retweet"
|
||||
style={{
|
||||
marginRight: 4,
|
||||
color: pal.colors.textLight,
|
||||
minWidth: 16,
|
||||
}}
|
||||
<Repost
|
||||
style={{color: pal.colors.textLight, marginRight: 3}}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<Text
|
||||
type="sm-bold"
|
||||
@@ -463,9 +461,10 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
includeReason: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
marginLeft: -20,
|
||||
marginLeft: -18,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {Text} from './text/Text'
|
||||
import {UserGroupIcon} from 'lib/icons'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export function EmptyState({
|
||||
testID,
|
||||
@@ -22,7 +23,9 @@ export function EmptyState({
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View testID={testID} style={[styles.container, pal.border, style]}>
|
||||
<View
|
||||
testID={testID}
|
||||
style={[styles.container, isWeb && pal.border, style]}>
|
||||
<View style={styles.iconContainer}>
|
||||
{icon === 'user-group' ? (
|
||||
<UserGroupIcon size="64" style={styles.icon} />
|
||||
@@ -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',
|
||||
|
||||
@@ -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<View>,
|
||||
) {
|
||||
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 <View style={style} {...props} />
|
||||
return <View ref={ref} style={style} {...props} />
|
||||
})
|
||||
|
||||
export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
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<ViewStyle>
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
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<ViewStyle>
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
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 ? (
|
||||
<RichText
|
||||
value={richText}
|
||||
style={[a.text_md, flatten(textStyle)]}
|
||||
style={a.text_md}
|
||||
numberOfLines={20}
|
||||
disableLinks
|
||||
/>
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
quoteTextStyle?: StyleProp<TextStyle>
|
||||
allowNestedQuotes?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {openLightbox} = useLightboxControls()
|
||||
@@ -63,11 +62,7 @@ export function PostEmbeds({
|
||||
moderation={moderation}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
<MaybeQuoteEmbed
|
||||
embed={embed.record}
|
||||
onOpen={onOpen}
|
||||
textStyle={quoteTextStyle}
|
||||
/>
|
||||
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -98,8 +93,8 @@ export function PostEmbeds({
|
||||
<MaybeQuoteEmbed
|
||||
embed={embed}
|
||||
style={style}
|
||||
textStyle={quoteTextStyle}
|
||||
onOpen={onOpen}
|
||||
allowNestedQuotes={allowNestedQuotes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,12 +52,12 @@ export function ListsScreen({}: Props) {
|
||||
<View style={s.hContentRegion} testID="listsScreen">
|
||||
<SimpleViewHeader
|
||||
showBackButton={isMobile}
|
||||
style={
|
||||
!isMobile && [
|
||||
pal.border,
|
||||
{borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth},
|
||||
]
|
||||
}>
|
||||
style={[
|
||||
pal.border,
|
||||
isMobile
|
||||
? {borderBottomWidth: hairlineWidth}
|
||||
: {borderLeftWidth: hairlineWidth, borderRightWidth: hairlineWidth},
|
||||
]}>
|
||||
<View style={{flex: 1}}>
|
||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||
<Trans>User Lists</Trans>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {colors, s} from 'lib/styles'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
import {ListMethods} from 'view/com/util/List'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Feed} from '../com/notifications/Feed'
|
||||
import {FAB} from '../com/util/fab/FAB'
|
||||
import {MainScrollProvider} from '../com/util/MainScrollProvider'
|
||||
@@ -145,7 +146,10 @@ export function NotificationsScreen({}: Props) {
|
||||
}, [isDesktop, pal, hasNew])
|
||||
|
||||
return (
|
||||
<View testID="notificationsScreen" style={s.hContentRegion}>
|
||||
<CenteredView
|
||||
testID="notificationsScreen"
|
||||
style={s.hContentRegion}
|
||||
sideBorders={true}>
|
||||
<ViewHeader
|
||||
title={_(msg`Notifications`)}
|
||||
canGoBack={false}
|
||||
@@ -173,6 +177,6 @@ export function NotificationsScreen({}: Props) {
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'PostThread'>
|
||||
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<ThreadNode>(
|
||||
POST_THREAD_RQKEY(resolvedUri.uri),
|
||||
)
|
||||
const thread = queryClient.getQueryData<ThreadNode>(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 (
|
||||
<View style={s.hContentRegion}>
|
||||
<View style={s.flex1}>
|
||||
{uriError ? (
|
||||
<CenteredView>
|
||||
<ErrorMessage message={String(uriError)} />
|
||||
</CenteredView>
|
||||
) : (
|
||||
<PostThreadComponent
|
||||
uri={resolvedUri?.uri}
|
||||
onPressReply={onPressReply}
|
||||
onCanReply={setCanReply}
|
||||
/>
|
||||
)}
|
||||
<PostThreadComponent
|
||||
uri={uri}
|
||||
onPressReply={onPressReply}
|
||||
onCanReply={setCanReply}
|
||||
/>
|
||||
</View>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<Animated.View
|
||||
|
||||
@@ -32,6 +32,7 @@ import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
|
||||
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
const HITSLOP_TOP = {
|
||||
top: 20,
|
||||
@@ -92,7 +93,7 @@ export function SavedFeeds({}: Props) {
|
||||
<ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
|
||||
<ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
|
||||
{noSavedFeedsOfAnyType && (
|
||||
<View style={[pal.border, {borderBottomWidth: 1}]}>
|
||||
<View style={[pal.border, {borderBottomWidth: hairlineWidth}]}>
|
||||
<NoSavedFeedsOfAnyType />
|
||||
</View>
|
||||
)}
|
||||
@@ -134,7 +135,7 @@ export function SavedFeeds({}: Props) {
|
||||
)}
|
||||
|
||||
{noFollowingFeed && (
|
||||
<View style={[pal.border, {borderBottomWidth: 1}]}>
|
||||
<View style={[pal.border, {borderBottomWidth: hairlineWidth}]}>
|
||||
<NoFollowingFeed />
|
||||
</View>
|
||||
)}
|
||||
@@ -298,9 +299,10 @@ function ListItem({
|
||||
<FeedSourceCard
|
||||
key={feedUri}
|
||||
feedUri={feedUri}
|
||||
style={[styles.noTopBorder, isPinned && {paddingRight: 8}]}
|
||||
style={[isPinned && {paddingRight: 8}]}
|
||||
showMinimalPlaceholder
|
||||
showSaveBtn={!isPinned}
|
||||
hideTopBorder={true}
|
||||
/>
|
||||
)}
|
||||
{isPinned ? (
|
||||
@@ -435,15 +437,12 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomWidth: hairlineWidth,
|
||||
},
|
||||
itemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
noTopBorder: {
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: hairlineWidth,
|
||||
},
|
||||
footerText: {
|
||||
paddingHorizontal: 26,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
ImageStyle,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
@@ -18,9 +22,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {createHitslop} from '#/lib/constants'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {MagnifyingGlassIcon} from '#/lib/icons'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||
import {s} from '#/lib/styles'
|
||||
@@ -46,6 +52,7 @@ import {Pager} from '#/view/com/pager/Pager'
|
||||
import {TabBar} from '#/view/com/pager/TabBar'
|
||||
import {Post} from '#/view/com/post/Post'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||
@@ -488,6 +495,9 @@ export function SearchScreen(
|
||||
|
||||
const [showAutocomplete, setShowAutocomplete] = React.useState(false)
|
||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
||||
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 (
|
||||
<View style={isWeb ? null : {flex: 1}}>
|
||||
<CenteredView
|
||||
@@ -689,12 +753,16 @@ export function SearchScreen(
|
||||
searchText={searchText}
|
||||
onSubmit={onSubmit}
|
||||
onResultPress={onAutocompleteResultPress}
|
||||
onProfileClick={handleProfileClick}
|
||||
/>
|
||||
) : (
|
||||
<SearchHistory
|
||||
searchHistory={searchHistory}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onItemClick={handleHistoryItemClick}
|
||||
onProfileClick={handleProfileClick}
|
||||
onRemoveItemClick={handleRemoveHistoryItem}
|
||||
onRemoveProfileClick={handleRemoveProfile}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<View style={{height: 200}} />
|
||||
@@ -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 (
|
||||
<CenteredView
|
||||
sideBorders={isTabletOrDesktop}
|
||||
@@ -880,12 +968,70 @@ function SearchHistory({
|
||||
height: isWeb ? '100vh' : undefined,
|
||||
}}>
|
||||
<View style={styles.searchHistoryContainer}>
|
||||
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
|
||||
<Text style={[pal.text, styles.searchHistoryTitle]}>
|
||||
<Trans>Recent Searches</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{selectedProfiles.length > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.selectedProfilesContainer,
|
||||
isMobile && styles.selectedProfilesContainerMobile,
|
||||
]}>
|
||||
<RNGHScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
horizontal={true}
|
||||
style={styles.profilesRow}
|
||||
contentContainerStyle={{
|
||||
borderWidth: 0,
|
||||
}}>
|
||||
{selectedProfiles.slice(0, 5).map((profile, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.profileItem,
|
||||
isMobile && styles.profileItemMobile,
|
||||
]}>
|
||||
<Link
|
||||
href={makeProfileLink(profile)}
|
||||
title={profile.handle}
|
||||
asAnchor
|
||||
anchorNoUnderline
|
||||
onBeforePress={() => onProfileClick(profile)}
|
||||
style={styles.profilePressable}>
|
||||
<Image
|
||||
source={{uri: profile.avatar}}
|
||||
style={styles.profileAvatar as StyleProp<ImageStyle>}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<Text style={[pal.text, styles.profileName]}>
|
||||
{truncateText(profile.displayName || '', 12)}
|
||||
</Text>
|
||||
</Link>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove profile`)}
|
||||
accessibilityHint={_(
|
||||
msg`Remove profile from search history`,
|
||||
)}
|
||||
onPress={() => onRemoveProfileClick(profile)}
|
||||
hitSlop={createHitslop(6)}
|
||||
style={styles.profileRemoveBtn}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={14}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</RNGHScrollView>
|
||||
</View>
|
||||
)}
|
||||
{searchHistory.length > 0 && (
|
||||
<View style={styles.searchHistoryContent}>
|
||||
<Text style={[pal.text, styles.searchHistoryTitle]}>
|
||||
<Trans>Recent Searches</Trans>
|
||||
</Text>
|
||||
{searchHistory.map((historyItem, index) => (
|
||||
{searchHistory.slice(0, 5).map((historyItem, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
@@ -982,11 +1128,57 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
selectedProfilesContainer: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
height: 80,
|
||||
},
|
||||
selectedProfilesContainerMobile: {
|
||||
height: 100,
|
||||
},
|
||||
profilesRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
},
|
||||
profileItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: 15,
|
||||
width: 78,
|
||||
},
|
||||
profileItemMobile: {
|
||||
width: 70,
|
||||
},
|
||||
profilePressable: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
profileAvatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 45,
|
||||
},
|
||||
profileName: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginTop: 5,
|
||||
},
|
||||
profileRemoveBtn: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 5,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 10,
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchHistoryContent: {
|
||||
padding: 10,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
searchHistoryTitle: {
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {ScrollView} from 'view/com/util/Views'
|
||||
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
@@ -103,10 +104,10 @@ function SettingsAccountCard({
|
||||
/>
|
||||
</View>
|
||||
<View style={[s.flex1]}>
|
||||
<Text type="md-bold" style={pal.text}>
|
||||
<Text type="md-bold" style={pal.text} numberOfLines={1}>
|
||||
{profile?.displayName || account.handle}
|
||||
</Text>
|
||||
<Text type="sm" style={pal.textLight}>
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
{account.handle}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -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 && <EmailConfirmationNotice />}
|
||||
|
||||
<View style={[s.flexRow, styles.heading]}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
<Text type="xl-bold" style={pal.text} numberOfLines={1}>
|
||||
<Trans>Signed in as</Trans>
|
||||
</Text>
|
||||
<View style={s.flex1} />
|
||||
@@ -791,6 +797,29 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Export My Data</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCard]}
|
||||
onPress={onPressDeactivateAccount}
|
||||
accessible={true}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Deactivate account`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens modal for account deactivation confirmation`,
|
||||
)}>
|
||||
<View style={[styles.iconContainer, dangerBg]}>
|
||||
<FontAwesomeIcon
|
||||
icon={'users-slash'}
|
||||
style={dangerText as FontAwesomeIconStyle}
|
||||
size={18}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={dangerText}>
|
||||
<Trans>Deactivate my account</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<DeactivateAccountDialog control={deactivateAccountControl} />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCard]}
|
||||
onPress={onPressDeleteAccount}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown, FadeOut} from 'react-native-reanimated'
|
||||
|
||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useComposerState} from 'state/shell/composer'
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
} from 'view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import {useBreakpoints, useTheme} from '#/alf'
|
||||
import {ComposePost} from '../com/composer/Composer'
|
||||
|
||||
const BOTTOM_BAR_HEIGHT = 61
|
||||
|
||||
export function Composer({}: {winHeight: number}) {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const state = useComposerState()
|
||||
const isActive = !!state
|
||||
useWebBodyScrollLock(isActive)
|
||||
@@ -49,20 +47,13 @@ export function Composer({}: {winHeight: number}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={styles.mask}
|
||||
aria-modal
|
||||
accessibilityViewIsModal
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(150)}
|
||||
exiting={FadeOut}
|
||||
<View style={styles.mask} aria-modal accessibilityViewIsModal>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isMobile && styles.containerMobile,
|
||||
pal.view,
|
||||
pal.border,
|
||||
!gtMobile && styles.containerMobile,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<ComposePost
|
||||
replyTo={state.replyTo}
|
||||
@@ -72,9 +63,9 @@ export function Composer({}: {winHeight: number}) {
|
||||
openPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<EmojiPicker state={pickerState} close={onClosePicker} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <LoggedOut />
|
||||
}
|
||||
if (hasSession && currentAccount?.deactivated) {
|
||||
return <Deactivated />
|
||||
if (hasSession && currentAccount?.signupQueued) {
|
||||
return <SignupQueued />
|
||||
}
|
||||
if (showLoggedOut) {
|
||||
return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
|
||||
}
|
||||
if (currentAccount?.status === 'deactivated') {
|
||||
return <Deactivated />
|
||||
}
|
||||
if (onboardingState.isActive) {
|
||||
return <Onboarding />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user