Compare commits

...

26 Commits

Author SHA1 Message Date
Eric Bailey 7d840faacc Make it safer 2024-06-04 14:19:34 -05:00
Eric Bailey ded615d9f9 Report persisted schema validation failures 2024-06-04 14:17:00 -05:00
Eric Bailey 551af88f22 [🙅] Remove fallback that's no longer valid (#4353)
* Remove fallback that's no longer valid

* Update test
2024-06-04 19:36:07 +01:00
Eric Bailey a49fe13223 Use recent convos for share via dialog (#4352) 2024-06-04 19:35:07 +01:00
Hailey 9f001526d3 Fix a few border nits (#4349)
* replace w/ hairline width

* no border for placeholder

* few notifications screen fixes tablet

* still show the border on desktop

* Simp

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 19:31:54 +01:00
surfdude29 d6b8313932 Mark accessibilityLabel and accessibilityHint for translation (#4351)
* mark `accessibilityLabel` and `accessibilityHint` for translation

* lint

* try again
2024-06-04 19:15:28 +01:00
Hailey e4b4d854d6 use rngh scrollview in search horizontal list (#4350) 2024-06-04 11:06:31 -07:00
dan c352e0f8b4 Fix forwarded ref (#4348) 2024-06-04 18:36:00 +01:00
Ryan Skinner e7968bc8d7 add profiles to search history (#4169)
* add profiles to search history

* increasing horizontal padding slightly

* tightening up styling

* fixing navigation issue

* making corrections

* Make the search history profiles a little smaller

* bug stomping

* Fix issues

* Persist taps

* Rm unnecessary

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 16:31:24 +01:00
dan 6f1589971c Fix missing top borders (#4346) 2024-06-04 11:07:11 +01:00
Samuel Newman 2ffb98e22a allow nested quotes in DMs (#4345) 2024-06-04 11:03:43 +01:00
Samuel Newman d918f8dc2a Composer - unbork web (#4344)
* reduce side gap + add overflow hidden

also remove the animations since they don't appear in prod, and are kinda broken

* removed fixed height to fix alt text
2024-06-04 10:58:09 +01:00
Samuel Newman d032734278 Composer - make bottom border more consistent when typing (#4343)
* floor values

* fix last line being obscured

* Rm unnecessary runOnUI

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 10:55:35 +01:00
Eric Bailey 3e1f076891 [🙅] Disambiguation of the deactivation (#4267)
* Disambiguation of the deactivation

* Snapshot crackle pop

* Change log context

* [🙅] Add status to session state (#4269)

* Add status to session state

* [🙅] Add new deactivated screen (#4270)

* Add new deactivated screen

* Update copy, handle logout

* Remove icons, adjust padding

* [🙅] Add deactivate account dialog (#4290)

* Deactivate dialog

(cherry picked from commit 33940e2dfe0d710c0665a7f68b198b46f54db4a2)

* Factor out dialog, add to delete modal too

(cherry picked from commit 47d70f6b74e7d2ea7330fd172499fe91ba41062d)

* Update copy, icon

(cherry picked from commit e6efabbe78c3f3d9f0f8fb0a06a6a1c4fbfb70a9)

* Update copy

(cherry picked from commit abb0ce26f6747ab0548f6f12df0dee3c64464852)

* Sizing tweaks

(cherry picked from commit fc716d5716873f0fddef56496fc48af0614b2e55)

* Add a11y label
2024-06-04 02:10:43 +01:00
Eric Bailey de93e8de74 [🐴] Post embeds polish (#4339)
* Handle message cleanup

* Handle last message in chat list

* Memoize lastMessage
2024-06-04 02:07:01 +01:00
Samuel Newman da96fb1ef5 Native formSheet for GIF select on iOS (#4328)
* native formsheet for gif select

* trigger confirm discard if have gif

* give modal a background color

* fix web top bar - unrelated but I cba to make a separate PR
2024-06-04 02:05:46 +01:00
Hailey b02445883a add an apk to production build outputs for Obtanium release support (#4317)
* add an apk to production build outputs

* test a build

* Revert "test a build"

This reverts commit f89bfeefb7.
2024-06-03 17:32:58 -07:00
Thomas Dickerson bd4703ca1e Support for Flickr album and group pool embeds (#3936)
* Support for Flickr album and group pool embeds

* Oops, forgot to add flickr to the persisted externalEmbeds schema

* Need a bigint since our id can have more than 52 bits...

* Remove unexpected trailing / from test data to match the expected behavior

* nits

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-06-03 17:29:45 -07:00
Samuel Newman 891b432ead Composer - add animated bottom border (#4325)
* start adding bottom border (wip)

* add content change listener

* add layout listener and move to hook

* remove logs

* use square-er image icon

* visually align bottom bar icons

* reduce keyboard vertical offset slightly

* only add border to top/bottom

* run worklet function on UI thread
2024-06-04 00:49:50 +01:00
dan 3b55f61d5f Avi follow experiment tweaks (#4341)
* Move avi button to visually align content

* Fix wrong prop warning

* Remove avi follow from post thread
2024-06-04 00:38:12 +01:00
Hailey 8c596b61c0 fix top border width for user list updates (#4340)
* fix nits in add/remove users from list screen

invert check

use `ViewHeader`

simplify

replace with hairline width

fix top border width for user list updates

* dont use `ViewHeader`

* update one more hairline
2024-06-03 16:34:37 -07:00
Bartosz Kaszubowski 44670c7ee2 Unify profile tabs and lists screens placeholders (#4315) 2024-06-03 16:05:26 -07:00
Hailey 8d8323421c remove resolution from post thread (#4297)
* remove resolution from post thread

nit

completely remove did cache lookup

move cache check for did to `usePostThreadQuery`

remove resolution from post thread

* helper function

* simplify

* simplify search too

* fix missing check for root or parent quoted post 🤯

* fix thread traversal
2024-06-03 23:58:16 +01:00
Yoshihiro Sugi 21d4d5f600 Fix filtering uris of fetchSubjects (#4324) 2024-06-03 23:41:03 +01:00
Hailey bda10510a4 use the new icon in reposted by (#4307)
* use the new icon in reposted by

* tweak
2024-06-03 23:35:57 +01:00
Hailey 16f295ca85 truncate if extending one line acct switcher (#4310) 2024-06-03 23:33:35 +01:00
58 changed files with 1900 additions and 612 deletions
@@ -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
+80
View File
@@ -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

+14
View File
@@ -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": {
+255
View File
@@ -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>
)
}
+16 -49
View File
@@ -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()
+1 -1
View File
@@ -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 -1
View File
@@ -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',
})
+1 -1
View File
@@ -13,7 +13,7 @@ export type LogEvents = {
withPassword: boolean
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
}
'notifications:openApp': {}
'notifications:request': {
+76
View File
@@ -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({
+21
View File
@@ -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
View File
@@ -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) {
+63 -14
View File
@@ -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>
)
}
+219
View File
@@ -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
}
+5 -1
View File
@@ -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
+13 -2
View File
@@ -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(
+13 -5
View File
@@ -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!)
}
}
}
+1 -1
View File
@@ -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)
}
}
+33 -13
View File
@@ -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)
}
}
}
}
+14 -11
View File
@@ -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)
}
}
+11 -3
View File
@@ -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)
}
}
+19
View File
@@ -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 {
+58 -29
View File
@@ -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": {
+6 -4
View File
@@ -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(),
}
}
+1 -1
View File
@@ -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'
+3 -2
View File
@@ -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
+125 -36
View File
@@ -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>
)
}
+9 -13
View File
@@ -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 -10
View File
@@ -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) {
+10 -12
View File
@@ -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(() => {
+53 -1
View File
@@ -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} />
</>
) : (
<>
+30 -29
View File
@@ -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,
+6 -1
View File
@@ -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>
)
+6 -11
View File
@@ -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
+2 -2
View File
@@ -97,8 +97,8 @@ export function AviFollowButton({
}),
a.absolute,
{
bottom: 0,
right: 0,
bottom: -1,
right: -1,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
},
+5 -1
View File
@@ -1 +1,5 @@
export {Fragment as AviFollowButton} from 'react'
import React from 'react'
export function AviFollowButton({children}: {children: React.ReactNode}) {
return children
}
+1 -9
View File
@@ -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,
+7 -8
View File
@@ -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',
+6 -3
View File
@@ -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',
+12 -9
View File
@@ -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>(
+22 -19
View File
@@ -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 -9
View File
@@ -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}
/>
)
}
+6 -6
View File
@@ -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>
+6 -2
View File
@@ -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>
)
}
+22 -33
View File
@@ -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
+7 -8
View File
@@ -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,
+199 -7
View File
@@ -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,
},
})
+32 -3
View File
@@ -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}
+11 -20
View File
@@ -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 />
}